TypeScript — 泛型與 Interface

泛型讓程式碼在保有型別安全的前提下具備彈性;Interface 定義物件契約。 兩者是 TypeScript 中最核心的抽象工具。

Interface

基本宣告與選用屬性

interface 定義物件的「形狀」(shape)。 屬性加 ? 為選用,加 readonly 為不可變。

interface User {
  readonly id: number      // 初始化後不可修改
  name: string
  email: string
  age?: number             // 選用屬性,型別為 number | undefined
  role: 'admin' | 'user'  // 字面值 union
}

const alice: User = { id: 1, name: 'Alice', email: 'a@a.com', role: 'admin' }
// alice.id = 2  ❌ 無法指派至唯讀屬性

定義方法

方法可用兩種語法定義。方法簡寫(method shorthand)與屬性函數語法在strictFunctionTypes 下對 covariance / contravariance 的處理略有不同, 一般情境選擇其一保持一致即可。

interface Calculator {
  // 方法簡寫(method shorthand)
  add(a: number, b: number): number

  // 屬性函數語法(property function)
  subtract: (a: number, b: number) => number

  // 多載宣告(overload signatures)
  parse(value: string): number
  parse(value: number): string
}

extends — 介面繼承

Interface 可繼承多個介面,繼承後必須滿足所有父介面的屬性。 若子介面重新宣告同名屬性,型別必須是父介面型別的子型別。

interface Animal {
  name: string
  breathe(): void
}

interface Pet {
  owner: string
}

// 多重繼承:同時繼承 Animal 與 Pet
interface Dog extends Animal, Pet {
  breed: string
  bark(): void
}

const rex: Dog = {
  name: 'Rex',
  owner: 'Alice',
  breed: 'Labrador',
  breathe() { console.log('...') },
  bark() { console.log('Woof!') },
}

Declaration Merging — 宣告合併

同名的 interface 會自動合併,這是 interface 獨有的特性(type alias 不支援)。 常用於擴充第三方套件的型別定義。

// 擴充 Express 的 Request 型別(實務中常見)
declare global {
  namespace Express {
    interface Request {
      currentUser?: { id: string; role: string }
    }
  }
}

// ---

// 同一個檔案內也能合併
interface Config {
  host: string
}
interface Config {
  port: number
}
// 等同於 { host: string; port: number }
const cfg: Config = { host: 'localhost', port: 3000 }

interface vs type alias — 如何選擇?

特性interfacetype alias
描述物件形狀
Declaration Merging
描述 Union / Intersection
Tuple / Primitive alias
Mapped Types / Conditional Types
implements(class 實作)✓(物件型別)

建議原則:定義物件 / API 回傳型別優先用 interface(可擴充、錯誤訊息更友善); 需要 Union、Tuple、Conditional Type 時改用 type

泛型(Generics)

泛型的本質:型別參數

泛型是「接受型別作為參數」的機制。用 <T> 宣告型別參數, 讓函數 / 介面 / class 在不同型別下都能正確運作,同時保留型別資訊(不像 any 會遺失型別)。

// ❌ any:失去型別資訊,呼叫端拿到 any
function identity_any(value: any): any { return value }

// ❌ 針對每種型別重複寫:不可擴展
function identity_string(value: string): string { return value }
function identity_number(value: number): number { return value }

// ✅ 泛型:一個函數,保有型別
function identity<T>(value: T): T { return value }

const s = identity('hello')  // T 推斷為 string,s: string
const n = identity(42)       // T 推斷為 number,n: number
const b = identity<boolean>(true)  // 明確指定 T

泛型函數實例

多個型別參數、回傳型別推斷,以及 React 中常見的泛型 API 封裝。

// 多型別參數
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second]
}
const p = pair('age', 30)  // [string, number]

// ---

// 陣列工具:回傳型別自動跟隨元素型別
function first<T>(arr: T[]): T | undefined {
  return arr[0]
}
const num = first([1, 2, 3])     // number | undefined
const str = first(['a', 'b'])    // string | undefined

// ---

// 封裝 fetch,帶型別安全
async function fetchData<T>(url: string): Promise<T> {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json() as T
}

// 使用:明確告知預期回傳型別
const user = await fetchData<User>('/api/user/1')
// user.name  ← TypeScript 知道 user 有 name 屬性

泛型 Interface

Interface 加上型別參數,可描述通用資料結構。

// 通用 API 回應包裝
interface ApiResponse<T> {
  data: T
  status: number
  message: string
  timestamp: string
}

// 使用時帶入具體型別
type UserResponse = ApiResponse<User>
type PostListResponse = ApiResponse<Post[]>

// ---

// 通用分頁結構
interface PaginatedResult<T> {
  items: T[]
  total: number
  page: number
  pageSize: number
  hasNextPage: boolean
}

// 實際型別:分頁使用者列表
type UserPage = PaginatedResult<User>

// ---

// 泛型 Repository 介面(後端 / 測試常見)
interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>
  findAll(): Promise<T[]>
  create(entity: Omit<T, 'id'>): Promise<T>
  update(id: ID, partial: Partial<T>): Promise<T>
  delete(id: ID): Promise<void>
}

泛型約束(Generic Constraints)

extends 限制型別參數必須滿足某個形狀, 才能在函數內安全存取特定屬性。

// ❌ 沒有約束:無法存取 .length
function logLength<T>(value: T) {
  console.log(value.length)  // 錯誤:T 上不存在 length
}

// ✅ 約束 T 必須有 length 屬性
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length)
  return value
}

logLength('hello')    // ✓ string 有 length
logLength([1, 2, 3])  // ✓ array 有 length
logLength(42)         // ❌ number 沒有 length

// ---

// keyof 約束:確保 key 存在於物件
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { id: 1, name: 'Alice', role: 'admin' }
const name = getProperty(user, 'name')   // string
const id = getProperty(user, 'id')       // number
//         getProperty(user, 'xyz')       // ❌ 'xyz' 不在 user 的 key 中

// ---

// 約束繼承 interface
interface HasId {
  id: number
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id)
}

// T 可以是任何有 id 屬性的型別
const user2 = findById(users, 1)    // T 推斷為 User
const post = findById(posts, 5)     // T 推斷為 Post

泛型預設型別

類似函數預設參數,泛型也可以設預設型別,在不指定型別時使用。

// 預設型別為 string
interface Stack<T = string> {
  push(item: T): void
  pop(): T | undefined
  peek(): T | undefined
  size: number
}

const strStack: Stack = { ... }         // Stack<string>(使用預設)
const numStack: Stack<number> = { ... } // Stack<number>

// ---

// React 元件常見的泛型 Props 模式
interface TableProps<T = Record<string, unknown>> {
  data: T[]
  columns: Array<{
    key: keyof T
    label: string
    render?: (value: T[keyof T], row: T) => React.ReactNode
  }>
}

function Table<T = Record<string, unknown>>({ data, columns }: TableProps<T>) {
  // ...
}

內建工具型別(Utility Types)

TypeScript 內建的泛型工具型別,底層都用 Mapped Type + Conditional Type 實作。

物件轉換類

interface User {
  id: number
  name: string
  email: string
  age: number
}

// Partial<T> — 所有屬性變為選用(PATCH 請求常用)
type UserPatch = Partial<User>
// { id?: number; name?: string; email?: string; age?: number }

// Required<T> — 所有屬性變為必填(和 Partial 相反)
type StrictUser = Required<User>

// Readonly<T> — 所有屬性變為唯讀
type ImmutableUser = Readonly<User>
const u: ImmutableUser = { id: 1, name: 'A', email: 'a@a', age: 20 }
// u.name = 'B'  ❌

// Pick<T, K> — 只保留指定屬性
type UserCard = Pick<User, 'id' | 'name'>
// { id: number; name: string }

// Omit<T, K> — 排除指定屬性(POST 建立時排除 id)
type CreateUserDto = Omit<User, 'id'>
// { name: string; email: string; age: number }

// Record<K, V> — 以 K 為 key、V 為 value 的物件型別
type RoleMap = Record<'admin' | 'user' | 'guest', string[]>
// { admin: string[]; user: string[]; guest: string[] }

型別操作類

type Status = 'pending' | 'active' | 'suspended' | 'deleted'

// Extract<T, U> — 取出 T 中屬於 U 的型別
type ActiveStatus = Extract<Status, 'pending' | 'active'>
// 'pending' | 'active'

// Exclude<T, U> — 從 T 中排除屬於 U 的型別
type VisibleStatus = Exclude<Status, 'deleted'>
// 'pending' | 'active' | 'suspended'

// NonNullable<T> — 排除 null 與 undefined
type SafeString = NonNullable<string | null | undefined>
// string

// ---

// ReturnType<T> — 取得函數回傳型別(不重複宣告)
function getUser() {
  return { id: 1, name: 'Alice', createdAt: new Date() }
}
type UserFromFn = ReturnType<typeof getUser>
// { id: number; name: string; createdAt: Date }

// Parameters<T> — 取得函數參數型別 tuple
type FetchParams = Parameters<typeof fetch>
// [input: RequestInfo | URL, init?: RequestInit]

// Awaited<T> — 取得 Promise resolve 後的型別
type UserValue = Awaited<Promise<User>>
// User

進階:條件型別與 infer

條件型別(Conditional Types)

語法類似三元運算子:T extends U ? X : Y。 讓型別可以根據條件動態決定。

// 基本條件型別
type IsString<T> = T extends string ? 'yes' : 'no'

type A = IsString<string>  // 'yes'
type B = IsString<number>  // 'no'

// ---

// 分配條件型別(Distributive):T 為 Union 時各自計算
type ToArray<T> = T extends unknown ? T[] : never

type StringOrNum = ToArray<string | number>
// string[] | number[]
// (不是 (string | number)[],而是各自分配)

// ---

// infer — 在 extends 條件中「推斷」型別並給予名稱
// 自己實作 ReturnType
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never

type R1 = MyReturnType<() => string>          // string
type R2 = MyReturnType<(n: number) => boolean> // boolean

// 取得 Promise 內的型別
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T

type U1 = UnwrapPromise<Promise<User>>  // User
type U2 = UnwrapPromise<string>         // string(不是 Promise,原樣回傳)

// 取得陣列元素型別
type ElementType<T> = T extends (infer E)[] ? E : never

type E1 = ElementType<User[]>    // User
type E2 = ElementType<string[]>  // string

Mapped Types — 映射型別

遍歷型別的所有 key 並逐一轉換——這是工具型別底層的實作原理。

// 自己實作 Partial
type MyPartial<T> = {
  [K in keyof T]?: T[K]
}

// 自己實作 Readonly
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

// ---

// 將所有屬性值轉為 getter 函數
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type UserGetters = Getters<{ name: string; age: number }>
// { getName: () => string; getAge: () => number }

// ---

// 過濾型別(只保留特定型別的屬性)
type FilterByType<T, ValueType> = {
  [K in keyof T as T[K] extends ValueType ? K : never]: T[K]
}

interface Mixed {
  id: number
  name: string
  active: boolean
  count: number
}

type OnlyNumbers = FilterByType<Mixed, number>
// { id: number; count: number }

React 實戰:泛型元件

泛型 Select 元件

元件加泛型讓 valueonChange的型別保持一致,防止傳入錯誤型別。

interface SelectProps<T> {
  options: T[]
  value: T
  onChange: (value: T) => void
  getLabel: (option: T) => string
  getValue: (option: T) => string | number
}

function Select<T>({ options, value, onChange, getLabel, getValue }: SelectProps<T>) {
  return (
    <select
      value={String(getValue(value))}
      onChange={e => {
        const selected = options.find(o => String(getValue(o)) === e.target.value)
        if (selected !== undefined) onChange(selected)
      }}
    >
      {options.map(option => (
        <option key={String(getValue(option))} value={String(getValue(option))}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  )
}

// 使用:T 自動推斷為 User
<Select
  options={users}
  value={selectedUser}
  onChange={setSelectedUser}
  getLabel={u => u.name}
  getValue={u => u.id}
/>

泛型 Custom Hook

封裝後端請求的通用 hook,型別安全地處理 loading / error / data。

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

function useFetch<T>(url: string): AsyncState<T> {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' })

  useEffect(() => {
    if (!url) return
    setState({ status: 'loading' })

    fetch(url)
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`)
        return r.json() as Promise<T>
      })
      .then(data => setState({ status: 'success', data }))
      .catch(error => setState({ status: 'error', error }))
  }, [url])

  return state
}

// 使用:T 指定為 User[]
function UserList() {
  const state = useFetch<User[]>('/api/users')

  if (state.status === 'loading') return <p>載入中...</p>
  if (state.status === 'error') return <p>{state.error.message}</p>
  if (state.status === 'success') {
    // TypeScript 確認:state.data 是 User[]
    return <ul>{state.data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
  }
  return null
}

常見面試考點

Q1
interface 和 type 的差異?

最關鍵的差異是 declaration merging(interface 可以,type 不行)與表達能力(type 可以表達 union、intersection、tuple 等複雜型別)。物件形狀優先用 interface,複雜型別運算用 type。

Q2
泛型和 any 的差異?

any 會完全放棄型別檢查,呼叫端拿到的也是 any。泛型會在呼叫時綁定具體型別,型別資訊完整保留。例如 identity<T>(v: T): T 傳入 string 就回傳 string,不是 any。

Q3
extends 在泛型中的兩種用法?

①約束型別參數:function f<T extends HasId>——限制 T 必須有 id 屬性。②條件型別:T extends string ? ... : ...——根據 T 是否符合形狀決定型別。

Q4
infer 的作用?

在條件型別的 extends 子句中,infer 可以「捕捉」並命名某個型別位置的推斷結果。最典型的用法是 ReturnType:從函數型別中提取回傳型別。

Q5
Partial、Pick、Omit 的底層實作?

Partial<T> = { [K in keyof T]?: T[K] }Pick<T, K> = { [P in K]: T[P] }Omit<T, K> = Pick<T, Exclude<keyof T, K>>。三者都是 Mapped Type 的應用。