上一篇結尾留了 identity<T> 的伏筆,這篇把泛型完整展開——從最基礎的型別參數,一路講到 keyof、typeof、conditional type、infer、mapped type、template literal type。下一篇 utility types 直接建立在這些工具上,先把這裡吃下去再繼續。
為什麼需要泛型
沒有泛型,型別只能寫死,每種型別都得複製一份函式:
function firstString(arr: string[]): string { return arr[0] }
function firstNumber(arr: number[]): number { return arr[0] }
或者退而求其次用 any,但這樣型別資訊就丟光了:
function first(arr: any[]): any { return arr[0] }
泛型的解法是讓「型別本身」也變成參數:
function first<T>(arr: T[]): T {
return arr[0]
}
const a = first(['a', 'b']) // T 推導為 string,回傳 string
const b = first([1, 2]) // T 推導為 number,回傳 number
const c = first<boolean>([true]) // 顯式指定
泛型函式
型別參數可以不只一個:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b]
}
const p = pair('hi', 42) // [string, number]
泛型介面與型別
interface 和 type alias 也都吃型別參數:
interface Box<T> {
value: T
}
const b: Box<number> = { value: 1 }
type Pair<A, B> = { first: A; second: B }
泛型類別
class 同理:
class Stack<T> {
private items: T[] = []
push(x: T) { this.items.push(x) }
pop(): T | undefined { return this.items.pop() }
}
const s = new Stack<number>()
s.push(1)
extends 約束
不加約束時,T 可以是任何型別,所以函式裡幾乎什麼都不能對它做:
function len<T>(x: T) {
return x.length // ❌ 不知道 T 有沒有 length
}
加上 extends 約束,TS 就知道 T 至少長什麼樣:
function len<T extends { length: number }>(x: T): number {
return x.length // ✅
}
len('hello') // T = string
len([1, 2, 3]) // T = number[]
len({ length: 5 }) // ✅
len(42) // ❌ number 沒 length
注意這裡的 extends 不是 class 繼承,而是「結構相容」檢查:T 必須至少具備 length: number 這個欄位。string、number[]、{ length: number } 都過得了,因為它們都有 length: number。
預設型別參數
型別參數也能給預設值,不指定時就用它:
interface ApiResponse<T = unknown> {
ok: boolean
data: T
}
const r: ApiResponse = { ok: true, data: 'whatever' } // T 預設 unknown
const r2: ApiResponse<{ id: number }> = { ok: true, data: { id: 1 } }
keyof:取出物件型別的所有 key
keyof 把一個物件型別的所有 key 變成字面值 union:
type User = { id: number; name: string; age: number }
type UserKeys = keyof User // 'id' | 'name' | 'age'
配泛型用,就能做出型別安全的屬性存取:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: 1, name: 'Jeremy' }
const id = getProp(user, 'id') // number
const name = getProp(user, 'name') // string
const x = getProp(user, 'foo') // ❌ 'foo' 不是 keyof user
T[K] 叫做 indexed access type,意思是「根據 K 拿出對應屬性的型別」。
typeof:從值推回型別
有時候值已經寫好了,型別不想再手抄一遍,typeof 可以直接從值推回型別:
const config = {
host: 'localhost',
port: 3000,
}
type Config = typeof config
// { host: string; port: number }
const status = ['idle', 'loading', 'done'] as const
type Status = typeof status[number]
// 'idle' | 'loading' | 'done'
第二個例子的 [number] 是 indexed access:Arr[number] 讀的是「陣列型別在任一數字索引位置的元素型別」。status 加了 as const,所以 typeof status 是 readonly ['idle', 'loading', 'done'],再取 [number] 就得到 'idle' | 'loading' | 'done'。
conditional type:型別層級的三元運算
型別也能寫條件判斷,語法長得跟三元運算子一樣:
type IsString<T> = T extends string ? true : false
type A = IsString<'hi'> // true
type B = IsString<42> // false
而且可以巢狀串接:
type TypeName<T> =
T extends string ? 'string'
: T extends number ? 'number'
: T extends boolean ? 'boolean'
: T extends Function ? 'function'
: 'object'
distributive conditional:union 上的分配律
conditional type 遇到 union 有個特別行為:
type ToArray<T> = T extends any ? T[] : never
type R = ToArray<string | number>
// (string | number) 分配 → string[] | number[]
// 而不是 (string | number)[]
為什麼結果是 string[] | number[]?因為在 T extends U ? X : Y 裡,若 T 是裸的型別參數(沒被包在 tuple 或其他結構裡)且傳入 union,TS 會把 union 的成員逐個套用條件、再把結果 union 起來——這就是 distributive。
不想要這個行為,把 T 包進 tuple 就能關掉:
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never
type R2 = ToArrayNoDistribute<string | number> // (string | number)[]
infer:從型別中抽取部分
infer 在 conditional type 裡宣告一個「待推導的型別變數」,讓 TS 幫你把型別的某一部分抓出來。最經典的是抽函式回傳型別:
type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never
type R1 = ReturnTypeOf<() => string> // string
type R2 = ReturnTypeOf<(x: number) => boolean> // boolean
抽陣列的元素型別:
type ElementType<T> = T extends (infer U)[] ? U : never
type E = ElementType<number[]> // number
抽 Promise 包的內部型別:
type Awaited2<T> = T extends Promise<infer U> ? U : T
type A = Awaited2<Promise<string>> // string
type B = Awaited2<number> // number
(標準庫已有 Awaited<T>,這裡只是示範原理。)
mapped type:把已有型別逐欄變換
mapped type 用 [K in keyof T] 遍歷一個型別的所有 key,逐欄做變換:
type ReadonlyAll<T> = {
readonly [K in keyof T]: T[K]
}
type Optional<T> = {
[K in keyof T]?: T[K]
}
type User = { id: number; name: string }
type R = ReadonlyAll<User> // { readonly id: number; readonly name: string }
type O = Optional<User> // { id?: number; name?: string }
還能用 as 在遍歷時重新命名 key(key remapping):
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type UserGetters = Getters<User>
// { getId: () => number; getName: () => string }
template literal type
字串字面值也能在型別層組合,union 會自動展開所有排列:
type Lang = 'zh' | 'en'
type Page = 'home' | 'about'
type Path = `/${Lang}/${Page}`
// '/zh/home' | '/zh/about' | '/en/home' | '/en/about'
搭配 mapped type 與內建的 Capitalize / Uppercase / Lowercase / Uncapitalize,威力很大。
一個實際組合範例
把前面的工具全部串起來:幫所有方法名加 Async 後綴,回傳值包成 Promise:
type Asyncify<T> = {
[K in keyof T as `${string & K}Async`]: T[K] extends (...a: infer A) => infer R
? (...a: A) => Promise<R>
: never
}
interface Sync {
load(id: number): string
save(name: string): boolean
}
type AsyncAPI = Asyncify<Sync>
// {
// loadAsync: (id: number) => Promise<string>
// saveAsync: (name: string) => Promise<boolean>
// }
mapped type + template literal + conditional type + infer,一次到位。
寫泛型的順序建議
- 先寫死型別,讓邏輯跑起來
- 把固定型別改成型別參數
- 加
extends約束縮小範圍 - 用
keyof、T[K]、infer、mapped type 慢慢精確化
不要一開始就追求最通用的寫法,否則容易卡在型別系統裡,忘了原本要實作什麼。
下一篇看標準庫把這些泛型工具包好的 utility types(Partial、Pick、Omit、Record⋯⋯)。多數時候不必自己寫 mapped type,直接組合現成的就夠。
Latest Updates
- 2026.06.11 Content updated
