TypeScript generic keyof infer conditional type

[TypeScript] 泛型

上一篇結尾留了 identity<T> 的伏筆,這篇把泛型完整展開——從最基礎的型別參數,一路講到 keyoftypeof、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 這個欄位。stringnumber[]{ 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 statusreadonly ['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,一次到位。

寫泛型的順序建議

  1. 先寫死型別,讓邏輯跑起來
  2. 把固定型別改成型別參數
  3. extends 約束縮小範圍
  4. keyofT[K]infer、mapped type 慢慢精確化

不要一開始就追求最通用的寫法,否則容易卡在型別系統裡,忘了原本要實作什麼。

下一篇看標準庫把這些泛型工具包好的 utility types(PartialPickOmitRecord⋯⋯)。多數時候不必自己寫 mapped type,直接組合現成的就夠。

Latest Updates

  • 2026.06.11 Content updated