TypeScript泛型约束让类型更有表达力
ChipCircuit
8 min read
泛型约束的价值不是"让类型变复杂",而是把"函数对参数 silently 假设的能力"写成编译器能查的契约——T extends ... 就是这条契约。没有约束的泛型等于 any,有了约束,调用方传错类型当场就被拦,而不是等到运行期在深处炸开。
不加约束的泛型太宽
function first<T>(arr: T[]): T {
return arr[0];
}
这里 T 没有任何能力假设,没问题。但想取长度就会报错:
function longest<T>(a: T, b: T): T {
return a.length > b.length ? a : b; // T 上没有 length
}
T 是任意类型,编译器拒绝猜测它有 length。这正是约束要补的洞:你心里其实假设了"能比较长度",那就把它写进签名。
extends 给泛型加能力
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest("ab", "cde"); // string
longest([1, 2], [1]); // number[]
T extends { length: number } 读作"T 必须至少有 length 数字字段"。字符串、数组都满足,约束把"能比较长度"这件事显式化,调用方传进不满足的类型当场被拦。T 仍保留具体类型(返回的是原类型而非笼统 {length}),所以调用方拿回的仍是 string 或 number[],能力不丢。
keyof 约束键名
keyof 把对象的键变成联合类型,配合泛型做"安全取字段":
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "tom" };
pick(user, "name"); // 返回 string
pick(user, "age"); // 报错:'age' 不在 keyof 里
K extends keyof T 保证 key 一定是 obj 真实存在的键,返回类型 T[K] 是对应值的类型,而不是笼统 any。这是把"可能拼错字段名"的 bug 挡在编译期。改字段名时,所有 pick 调用点会一并报错,重构安全网由此而来。
泛型约束配合默认类型
interface ApiResp<T = unknown> {
code: number;
data: T;
}
ApiResp; // data 是 unknown
ApiResp<string>; // data 是 string
默认类型让调用方在不在乎具体结构时不必每次写全,又保留需要精确时的能力。默认类型还能参与约束:T extends Base = Default,既限定范围又给兜底。
条件类型与 infer
约束之外,条件类型能在类型层面做分支,infer 提取内部类型:
type ElementType<T> = T extends (infer E)[] ? E : T;
type A = ElementType<number[]>; // number
type B = ElementType<string>; // string
这把"数组元素是什么"这类问题从运行期提到类型层,配合约束一起用,能写出既安全又灵活的工具类型。标准库的 ReturnType、Parameters 都是 infer 的典型应用。
边界与坑
过度约束会退化成重复。若 T extends SomeConcrete,往往不如直接用 SomeConcrete。约束应只声明"真正用到的那几个字段",别把整个具体类型搬进 extends,否则泛型失去泛化意义,又写回了具体类型。
条件类型 T extends U ? X : Y 在分发上有陷阱:裸类型参数 T 遇到联合类型会逐成员分发;想关掉分发,用 [T] extends [U] ? X : Y 包一层元组。
约束写错方向会反向限制。把"需要带 id"的对象约束成 T extends { id: number },结果业务里 id 是字符串的地方全报不兼容——约束应按真实最小能力来,而不是按你手头某个样本。正确的写法常是 T extends { id: string | number }。
any 会击穿约束。any 满足任何 extends,用它作实参等于关掉检查;需要"我也不知道但别当它 Any"时用 unknown,再在内部收窄。unknown 不瞒编译器,调用方必须自己做类型判断才能用,正好逼出该有的守卫。
泛型约束的尺度是:越靠近对外接口越要精确,越靠近内部实现越可放松。把约束写在公共函数签名上,等于给调用方一份编译期文档——比注释里写"请保证传入有 length"可靠得多。约束不是越多越好,而是"只约束你真正依赖的那点能力",这样既挡住错误,又不给使用方添无谓的负担。
约束与函数重载的取舍
当返回类型要随输入类型变化时,T extends ... 配合 T[K] 往往比函数重载更省。重载要写 N 个签名加一个实现,约束只写一处;但重载在"参数类型和返回类型映射复杂、且想给调用方不同入参对应不同精确签名"时更直观。经验:映射规则能用 keyof/T[K] 表达就选约束,映射是离散的几条分支就选重载。
约束还能和映射类型配合批量改字段:type Readonly<T> = { readonly [K in keyof T]: T[K] } 把每个字段变只读,Partial<T>、Pick<T>、Record<K,T> 同理。这些工具类型本质都是"在类型层面遍历键、加约束或变换",理解 keyof 与索引访问 T[K] 就掌握了它们的底层。
泛型默认值别和 any 混用。T = any 看似"不强制",实则把下游全变 any,检查形同虚设;需要宽松时用 T = unknown,调用方取用时仍被迫收窄,保护不丢。约束写 T extends object 比写 T extends {} 更准确——{} 几乎接受一切(包括 null 在某些上下文的误判),object 才真的指"非原始类型"。
约束越精确,IDE 的自动补全越准。因为 T 被限定成"至少有这些字段",编辑器能基于约束给出成员提示,而不是面对 any 哑火。这是类型约束除"防错"外的第二重收益:它顺带提升了编码时的可发现性。
1 view
0 likes
0 comments