阅读提要

泛型不是「加个尖括号就完事」。约束写松了等于没写,写紧了复用性归零。这篇讲四种约束粒度和它们的适用边界。

开始阅读

字号

泛型约束到底在约束什么

写 function pick<T>(obj: T, key: string) 时,T 是自由的,返回值也是自由的——这等于没写类型。泛型约束的作用是:告诉编译器这个类型至少具备什么能力。

写约束的四种常见粒度,从松到紧。

约束的四种粒度

第一种:extends 结构约束

最基础的形式,约束「至少长这样」:

function pluck<T extends { id: number }>(list: T[]): number[] {
  return list.map(item => item.id)
}

关键在于 TypeScript 的结构化类型:任何带 id: number 的对象都满足,不需要显式 implements。这比 Java 那种名义类型灵活得多。

需要注意的是,extends 在泛型里是「可赋值」而非「继承」。所以 T extends object 会排除 null、undefined 和原始类型,T extends Record<string, unknown> 则排除数组(数组没有字符串索引)。

第二种:keyof 键名约束

当你需要「传一个对象和它自己的键」时:

function get<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

K extends keyof T 保证传进来的键一定存在,返回值 T[K] 是精确的类型而不是联合类型。调用 get(user, 'name') 返回 string,写错键名直接编译报错。

这里有个细节:K 必须显式声明,不能省成 function get<T>(obj: T, key: keyof T)——那样返回值只能推出所有值类型的联合,丢失精确性。

第三种:条件类型与 infer

需要「从类型里反推」时用这个:

type Awaited2<T> = T extends Promise<infer U> ? U : T
type ElementOf<T> = T extends (infer E)[] ? E : never

infer 声明一个待推断的类型变量,位置决定了怎么解构。

条件类型在遇到联合类型时是分发的:

type ToArray<T> = T extends unknown ? T[] : never
type R = ToArray<string | number>   // string[] | number[]

如果不想分发,用元组包一层:

type ToArrayAll<T> = [T] extends [unknown] ? T[] : never
type R2 = ToArrayAll<string | number>   // (string | number)[]

这个差别在处理 T | undefined 时特别容易踩:分发的版本会得到 string[] | undefined[],而不是你想要的 string[] | undefined。

第四种:默认参数降低调用成本

约束可以带默认值,让常见调用不必显式传类型:

interface ApiResponse<T = unknown> {
  code: number
  data: T
  message: string
}

// 不关心 data 时直接用
const res: ApiResponse = await fetchJson('/ping')

默认值的另一个用法是配约束:

function createStore<S extends object = Record<string, never>>(initial?: S) {
  return { state: initial ?? ({} as S) }
}

一个综合案例

需求:写一个 groupBy,按对象的某个键分组,返回值要精确。

function groupBy<T, K extends keyof T>(list: T[], key: K): Map<T[K], T[]> {
  const map = new Map<T[K], T[]>()
  for (const item of list) {
    const k = item[key]
    const bucket = map.get(k)
    if (bucket) bucket.push(item)
    else map.set(k, [item])
  }
  return map
}

const users = [{ id: 1, role: 'admin' }, { id: 2, role: 'user' }]
const byRole = groupBy(users, 'role')   // Map<'admin' | 'user', {...}[]>

T[K] 让返回值精确到字面量联合。如果写成 Map<string, T[]>,调用方就得自己做类型收窄。

约束写多紧算合适

三条经验:

约束只写「函数体真正用到的能力」。 函数里只调了 item.id,就只约束 { id: number },不要约束成完整的 User。多约束一个字段,就多排除一批本可以复用的调用。

能用 unknown 就不要用 any。 T extends any 等于没有约束,unknown 至少强制你收窄。

报错信息是设计的一部分。 泛型报错经常出现「类型实例化过深」这种天书。给类型参数起有意义的名字(TInput、TResult 而不是 T、U),能显著减少排查时间。

收尾

四种写法不是替代关系,是四个工具:结构约束管「至少有什么」,键名约束管「必须是它的键」,条件类型管「从形状里反推」,默认值管「常见情况少写点」。

判断标准很简单:如果你在函数体里对参数做了 as,说明约束没写对。

你认为这篇文章怎么样?

还没有表情反馈

分享文章