泛型约束与默认类型参数
从「按任意字段排序」的真实需求切入,讲透 extends 约束、keyof 约束、多重约束与默认类型参数,以及约束过宽、过严、顺序错误三类高频坑。
0. 真实场景:按任意字段排序
FANDEX 的文档列表要支持排序:按标题、按更新时间、按字数。第一版写死了字段:
function sortByDate(docs: Doc[]): Doc[] {
return [...docs].sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
}产品说「再加个按标题排」,你复制一个 sortByTitle;按字数排,再复制一个。三个函数只有属性名不同——把属性名参数化就行:
function sortBy<T>(items: T[], key: string) {
return [...items].sort((a, b) => String(a[key]).localeCompare(String(b[key])));
}
// ~~~~~ 报错:T 上没有索引签名,key: string 也太宽TS 拒绝了:T 是「任意类型」,凭什么都取 a[key]?解决它的机制就是本篇主题:约束(constraint)——告诉编译器「T 至少长成什么样子」。
1. 一句话理解
约束 = 给类型参数画底线。
T extends U读作「T 必须是 U 的子类型」:调用方只能传满足 U 的类型,函数体内你获得了 U 上的全部能力。没有约束的 T 是「一无所知的类型」,写了约束的 T 才「有基础能力可用」。
2. 动手:三级约束,逐步收紧
2.1 第一级:结构约束,换取字段访问权
先解决「T 什么都干不了」。如果函数体只需要「有 id 字段」,就只约束这一点:
interface Doc {
id: string;
title: string;
wordCount: number;
updatedAt: string;
}
function exportCsv<T extends { id: string }>(items: T[]): string {
return items.map((item) => item.id).join(','); // 约束给了 item.id 的访问权
}
exportCsv(docs); // Doc 有 id,通过
exportCsv([{ name: 'tag' }]); // 报错:缺少 id 属性约束写在 extends 后面,内容是「最低要求」:传 Doc、Tag、任何带 id: string 的类型都能通过,还能保留各自更完整的类型(T 仍然是 Doc 而不是退化成 { id: string }——这一点比把参数直接写成 { id: string } 强)。
2.2 第二级:keyof 约束,把 key 锁进已知键
回到排序场景。key: string 意味着能传 'ttile' 这种拼错的键,运行时排了个寂寞。把键约束成 T 的已知键:
function sortBy<T>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => {
const va = a[key];
const vb = b[key];
if (typeof va === 'string' && typeof vb === 'string') {
return va.localeCompare(vb);
}
if (typeof va === 'number' && typeof vb === 'number') {
return va - vb;
}
return 0;
});
}
const docs: Doc[] = [/* ... */];
sortBy(docs, 'updatedAt'); // 正常
sortBy(docs, 'ttile'); // 编译报错:'"ttile"' 不能赋给 keyof Doc变化点:key 的类型从 string 变成 keyof T(T 的全部键的联合),a[key] 的类型也自动是 T[keyof T]。拼错键名从「运行时静默失败」变成「编译期红线」,这是 keyof 约束最常用的姿势。keyof 的完整语义见keyof、typeof 与索引访问类型。
2.3 第三级:接口约束,要求「会做某件事」
如果想让 sortBy 拒绝不可比较的字段,可以要求值类型自己会比较——约束指向接口,而不是写死结构:
interface Comparable {
compareTo(other: Comparable): number;
}
function sortByC<T extends Record<string, Comparable>>(items: T[], key: keyof T): T[] {
return [...items].sort((a, b) => a[key].compareTo(b[key]));
}三种约束覆盖日常九成需求:结构约束(有这些字段)、keyof 约束(键必须是已知键)、接口约束(有这些方法)。
3. 多重约束:一个 T,两份要求
约束可以叠加:T extends A & B,T 必须同时满足两者。
// 同时要求:能被序列化(有 id),且带有审计字段
type Auditable = { createdBy: string; createdAt: string };
function auditLog<T extends { id: string } & Auditable>(item: T): string[] {
return [
`entity=${item.id}`,
`by=${item.createdBy}`,
`at=${item.createdAt}`,
];
}A & B 是交叉类型,这里充当「要求清单」。实践中更常见的写法是把要求提取成命名接口(如上面的 Auditable),约束处写 T extends Entity & Auditable——需求变化时只改接口。
4. 默认类型参数:让调用方少写尖括号
类型参数可以带默认值,规则与函数默认参数一致:有默认值的参数必须排在无默认值的后面。
// 分页请求:元素类型必填,排序方向默认 'desc',页大小默认 20
interface PageQuery<T, Order extends 'asc' | 'desc' = 'desc', Size extends number = 20> {
items: T[];
order: Order;
size: Size;
}
const p1: PageQuery<Doc> = { items: [], order: 'desc', size: 20 };
const p2: PageQuery<Doc, 'asc', 50> = { items: [], order: 'asc', size: 50 };
// const p3: PageQuery = ... 报错:T 没有默认值,必须显式传两个典型用途:
- 统一约定:
Repo<T, ID = string>把「主键都是 string」这个团队约定写进类型,调用方Repo<Doc>两个词说完; - 约束收紧默认值:注意
Order extends 'asc' | 'desc' = 'desc'的结构——约束在前、默认值在后,等号右边的值必须满足左边的约束。
5. 为什么:约束解决的是「信息不足」
无约束泛型的本质困境:函数体内对 T 一无所知,任何属性访问、方法调用都没有依据。约束的本质是递给函数一张最低配置单:
T extends { id: string }:保证「至少有个 string 类型的 id」;key extends keyof T:保证「键都在我认知范围内」;T extends Comparable:保证「会自我比较」。
调用方视角则相反:约束是准入门槛,传进来的类型不满足就报错。设计和评审泛型 API 时,先问两个问题:函数体需要 T 的哪些能力(决定约束内容)?调用方最常见的类型能不能直接通过(决定约束别过严)?
TypeScript 的泛型是「擦除式」的:编译后类型参数消失,运行时没有任何泛型痕迹。这与 Java 的类型擦除类似、与 C# 的具现化泛型不同——所以别在泛型代码里试图对 T 做运行时判断(如 value instanceof T 编译不过),需要运行时类型信息时把「类型的构造器」作为普通参数传进来。
6. 坑点与自检
坑 1:约束过宽,形同虚设
function sortBy<T extends Record<string, any>>(items: T[], key: string) { /* ... */ }
// key 是 string:拼错键名照样编译通过,运行时拿 undefined 排序any 约束等于没约束。能用 keyof T 就别用 string。
坑 2:约束过严,调用方被逼断言
// 要求太多字段,调用方手里只有部分数据时被迫 as
function publish<T extends Doc & { review: Review }>(d: T) { /* ... */ }
// 实际调用:publish(draft as Doc & { review: Review }) —— 断言文化就是这么养成的约束只写「函数体真正用到」的字段,多的要求是给调用方加税。评审时逐个字段问「函数体用了吗」。
坑 3:默认类型参数顺序错误
interface Bad<Size = 20, T> { } // 报错:必选参数不能跟在可选参数后面
interface Good<T, Size = 20> { } // 正确坑 4:在擦除式泛型里找运行时类型
function clone<T>(value: T): T {
// return new T(); // 编译不过:运行时没有 T
return structuredClone(value); // 用能干这事的运行时 API 替代
}坑 5:默认值不满足自己的约束
interface Q<T extends { id: string } = { name: string }> { }
// 报错:默认值 { name: string } 不满足约束 { id: string }自检清单
- 能说出
T extends U对「函数体」和「调用方」分别意味着什么 - 能用
keyof T约束把键参数化,并解释它防住了什么 - 会写多重约束
T extends A & B和带默认值的类型参数 - 知道默认类型参数必须排在必选参数之后、默认值必须满足约束
- 知道泛型是擦除式的,运行时拿不到 T
7. 练习
- 把第 2.2 节的
sortBy升级:新增direction: 'asc' | 'desc' = 'asc'参数。想想它适合放函数参数层还是类型参数层,说出理由。 - 复刻标准库
Pick<T, K extends keyof T>的签名(不用写实现),解释为什么第二个参数要写extends keyof T而不是keyof T。 - 给 FANDEX 场景设计一个
createRepo<T extends { id: string }, ID = string>()工厂签名:T 约束必须有 id,ID 默认 string。试着故意写出违反约束的调用,观察报错信息。