本节目标:读完这一节,你能解释「为什么裸泛型在函数体里几乎不能用」;能用
extends为类型参数加上对象形状约束;能把keyof与索引访问类型组合进约束里,写出get(obj, key)这类既安全又好用的签名;能判断什么时候该用交叉类型做多重约束;并且能看懂Type 'string' does not satisfy the constraint 'Record<string, unknown>'这类报错到底在说什么。
8.1 泛型约束(extends)
在 4.3 泛型函数入门 里,我们已经见过最朴素的泛型:
function identity<T>(value: T): T {
return value;
}
这个函数的类型参数 T 是完全自由的——它可以被实例化成 number、string、{ id: number },甚至是 never。自由带来通用,但也带来一个麻烦:在函数体内部,编译器对 T 几乎一无所知,因此除了原样返回,你什么都做不了。
这一节要解决的正是这个问题:如何在不牺牲通用性的前提下,告诉编译器「T 至少长什么样」。答案就是 extends 约束。
裸泛型的困境
先看一个真实的失败案例。假设我们要写一个「取长度」的函数:
function len<T>(value: T): number {
return value.length;
// ❌ Property 'length' does not exist on type 'T'.
}
报错非常直白:编译器不知道 T 有没有 length 属性。因为 T 可能被实例化成 number、boolean 这些根本没有 length 的类型,所以访问 value.length 是不安全的。
很多初学者的第一反应是加断言:
function len<T>(value: T): number {
return (value as any).length; // 😖 能用,但类型安全荡然无存
}
as any 确实让编译通过了,但它把责任全部推给了调用者:调用 len(42) 时编译期毫无警告,运行时却得到 undefined。这等于在泛型里开了一个「后门」,TypeScript 的全部价值在此处归零。
正确的做法不是断言,而是约束:
function len<T extends { length: number }>(value: T): number {
return value.length; // ✅ 编译器知道 T 一定有 length
}
<T extends { length: number }> 的意思是:T 可以是任何类型,前提是它必须能赋值给 { length: number }。这句话把「任意类型」收窄成了「至少有一个数字型 length 属性的类型」。
于是三种调用结果泾渭分明:
len("hello"); // ✅ string 有 length,返回 5
len([1, 2, 3]); // ✅ 数组有 length,返回 3
len({ length: 10 }); // ✅ 恰好匹配,返回 10
len(42);
// ❌ Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
这就是约束的全部意义:在编译期把不合法的调用挡在门外,同时让函数体内部获得完整的类型信息。它不是「限制能力」,而是「换取能力」——你付出了通用性的一点点代价,换来了函数体里可以安全地做任何事。
约束的本质是「可赋值性」
理解 extends 有一个关键认知:T extends U 里的 extends 与类继承里的 extends 不是一回事。这里的 extends 表达的是「可赋值性」(assignability),即「凡是 T 的值,都一定是合法的 U」。
这一点在结构化类型系统下尤其重要。回忆 5.3 结构化类型与两者取舍 里讲的:TypeScript 看的是形状而不是名字。所以下面这些写法全都成立:
interface HasId {
id: number;
}
function findById<T extends HasId>(list: T[], id: number): T | undefined {
return list.find((item) => item.id === id);
}
// 只要形状对得上,不需要显式 implements HasId
findById([{ id: 1, name: "Ada" }], 1); // ✅ 返回 { id: number; name: string } | undefined
findById([{ name: "Ada" }], 1);
// ❌ Property 'id' is missing in type '{ name: string; }' but required in type 'HasId'.
注意返回类型 T | undefined——这是约束带来的额外红利:因为 T 保留了「实际传入的那个具体类型」,findById([{ id: 1, name: "Ada" }], 1) 的返回值里 name 字段依然可见。如果签名写成 (list: HasId[], id: number): HasId | undefined,返回值的类型信息就退化成了 HasId,调用者再也拿不到 name。
记住这条原则:约束用来放宽入口,泛型参数用来保住出口。 二者缺一不可。
约束的形状写法
约束位置可以写任意类型表达式,包括接口、类型别名、字面量对象类型、甚至联合类型。
// 1. 内联字面量对象类型
function logLabel<T extends { label: string }>(item: T): void {
console.log(item.label);
}
// 2. 具名接口(可读性最好,推荐在导出 API 上使用)
interface Serializable {
serialize(): string;
}
function save<T extends Serializable>(entity: T): string {
return entity.serialize();
}
// 3. 类型别名
type Point = { x: number; y: number };
function translate<T extends Point>(p: T, dx: number, dy: number): T {
return { ...p, x: p.x + dx, y: p.y + dy };
}
第三种写法值得多看一眼:{ ...p, x: p.x + dx } 的类型被推断为 T & { x: number; y: number },由于 T extends Point,这个交叉类型可以赋值给 T,所以返回注解 T 能通过检查。这是约束 + 展开运算符的经典组合,在写不可变更新工具时非常常用。
keyof 与索引访问:约束的最强形态
如果说 extends { length: number } 是入门,那么 K extends keyof T 就是泛型约束的主战场。它解决的是「安全地按名字取属性」这个高频需求。
先看裸写法的问题:
function getProp<T>(obj: T, key: string) {
return obj[key];
// ❌ Element implicitly has an 'any' type because expression of type 'string'
// can't be used to index type 'T'.
}
key: string 太宽了——编译器无法保证这个字符串真的存在于 T 上。改成 keyof T 就精确了:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // ✅ 完全安全
}
const user = { id: 1, name: "Ada", active: true };
const name = getProp(user, "name"); // 推断为 string
const id = getProp(user, "id"); // 推断为 number
const bad = getProp(user, "email");
// ❌ Argument of type '"email"' is not assignable to parameter of type '"id" | "name" | "active"'.
这段代码里有三个知识点,值得逐个拆开:
| 位置 | 写法 | 作用 |
|---|---|---|
| 类型参数 | K extends keyof T | 把 K 限定为 T 的键名联合 |
| 参数类型 | key: K | 让 K 由实参推断出来,而不是由调用者手写 |
| 返回类型 | T[K] | 索引访问类型,按 K 精确取出对应的值类型 |
返回类型 T[K] 是整个签名的点睛之笔。它意味着 getProp(user, "id") 的返回值是 number 而不是 string | number | boolean——键与值的类型被绑定了。这正是 9.1 keyof·typeof 与索引访问类型
会展开的「类型级编程」的第一步。
再看一个实用变体:把值改掉并保持类型正确。
function setProp<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}
const next = setProp(user, "active", false); // ✅
setProp(user, "active", "yes");
// ❌ Argument of type 'string' is not assignable to parameter of type 'boolean'.
value: T[K] 让第三个参数的合法取值随第二个参数变化——传 "active" 就必须传 boolean,传 "name" 就必须传 string。这种「参数之间的联动约束」是手写重载几乎无法优雅表达的,而泛型一行搞定。
一个类型参数约束另一个
约束表达式里可以引用前面已经声明的类型参数,形成参数之间的依赖关系。除了上面的 K extends keyof T,另一个常见模式是「按分组键取值」:
type GroupKey<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
function groupBy<T, K extends GroupKey<T>>(list: T[], key: K): Map<T[K], T[]> {
const result = new Map<T[K], T[]>();
for (const item of list) {
const k = item[key];
const bucket = result.get(k) ?? [];
bucket.push(item);
result.set(k, bucket);
}
return result;
}
const users = [
{ name: "Ada", team: "core", age: 36 },
{ name: "Linus", team: "kernel", age: 54 },
];
groupBy(users, "team"); // ✅ Map<string, {...}[]>
groupBy(users, "age"); // ❌ 'number' 不满足 GroupKey 的约束
这里 GroupKey<T> 是一个映射类型,只保留「值类型是 string」的那些键名。K extends GroupKey<T> 于是把第二个参数限定成了 "name" | "team"。这段代码看起来复杂,但它的价值很直观:把「只有字符串字段才能做分组键」这条业务规则,写进了类型系统。映射类型本身的机制见 9.2 映射类型与键重映射(as)
,现在只需理解「约束表达式可以任意复杂,只要它能算出一个类型」。
需要注意一个限制:类型参数之间不能循环引用。<T extends U, U extends T> 这类写法会报 Type parameter 'T' has a circular constraint.。约束关系必须是有向无环的。
多重约束:交叉类型
TypeScript 不支持 T extends A, B 这种写法——逗号在类型参数列表里表示「声明下一个类型参数」。要做多重约束,必须用交叉类型 &:
interface HasId { id: number }
interface HasTimestamps { createdAt: Date; updatedAt: Date }
function touch<T extends HasId & HasTimestamps>(entity: T): T {
return { ...entity, updatedAt: new Date() };
}
touch({ id: 1, createdAt: new Date(), updatedAt: new Date(), title: "post" }); // ✅
touch({ id: 1, createdAt: new Date() });
// ❌ Property 'updatedAt' is missing ...
A & B 的含义是「同时满足 A 和 B」。对约束来说,这恰好等价于「两个条件都要成立」。如果嫌交叉类型写在参数列表里太长,可以先抽成具名类型:
type Entity = HasId & HasTimestamps;
function touch<T extends Entity>(entity: T): T {
return { ...entity, updatedAt: new Date() };
}
一个易错点:交叉类型在原始类型上会退化成 never。string & number 没有任何值能满足,所以下面的写法会让函数永远无法被调用:
function broken<T extends string & number>(value: T) { /* ... */ }
// broken("a"); // ❌ 'string' 不满足 'string & number'
而对象类型的交叉是「合并成员」,通常不会出现这种问题——这也是为什么多重约束几乎只用在对象形状上。
约束与默认值的分工
约束只回答「允许什么」,不回答「不写时用什么」。看这个例子:
// 没有推断候选时,类型参数回退到约束
function parse<T extends Record<string, unknown>>(raw: string): T {
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("需要对象");
return value as T; // 只验证对象形状,没有验证 T 的字段
}
const a = parse('{"x":1}'); // ⚠️ T 推断为 Record<string, unknown>,丢了精确性
两个 parse 版本应分别运行,不要放在同一文件中。这里的断言只是教学演示:声明 <{ x: number }> 不会验证 JSON 中的 x;真实 API 需要第 13 章的字段校验。这里 T 没有实参可供推断(raw 是 string,与 T 无关),编译器只能退回约束本身,返回字段类型仍是 unknown。可以给 T 指定默认回退类型,但默认值不会自动识别 JSON 字段;本例的默认值与约束相同,精确类型仍需显式指定:
function parse<T extends Record<string, unknown> = Record<string, unknown>>(
raw: string,
): T {
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("需要对象");
return value as T;
}
const b = parse<{ x: number }>('{"x":1}'); // ✅ 显式指定,得到精确类型
约束与默认值的组合——<T extends C = D>,其中 D 必须满足 C——是设计泛型 API 时的标准配置。默认值能不能省略、推断又是按什么优先级发生的,正是下一节 8.2 泛型默认值与类型推断
的主题。
常见报错速查
约束相关的报错措辞高度固定,认得它们能省下大量搜索时间。
| 报错信息 | 含义 | 修法 |
|---|---|---|
Property 'x' does not exist on type 'T' | 忘了加约束,函数体访问了未知属性 | 给 T 加 extends 约束 |
Type 'X' does not satisfy the constraint 'Y' | 调用时传入的类型不满足约束 | 改实参类型,或放宽约束 |
Argument of type '"email"' is not assignable to parameter of type '"id" | "name"' | 键名不在 keyof T 里 | 检查拼写或补上该属性 |
Type parameter 'T' has a circular constraint | 类型参数互相约束成环 | 打破循环,抽出一个不依赖的基础类型 |
Generic type 'X' requires N type argument(s) | 泛型没有默认值却省略了实参 | 补上实参或给默认值 |
Could not find a matching overload | 重载与泛型混用时的推断失败 | 简化重载,优先用泛型 + 约束 |
最后一条报错在工程代码里很常见,往往出现在「既写了重载又写了泛型」的函数上。经验法则是:能用泛型约束表达的关系,就不要写重载——重载是手写枚举,泛型是自动推导,后者可维护性高得多。
约束不是万能的
必须诚实地说一句:约束只做结构性检查,它不校验值的语义。下面这段代码完全通过编译:
function divide<T extends { value: number }>(input: T): number {
return 100 / input.value;
}
divide({ value: 0 }); // ✅ 编译通过,运行时得到 Infinity
value: 0 完全满足 { value: number }。要拦住它只能靠运行时校验——这属于 13.2 Zod 模式验证与类型推导
的范畴。理解了「约束管结构、运行时管数值」,你就不会对类型系统产生过高的期待。想进一步了解泛型 API 的设计取舍,可以延伸阅读站内的 TypeScript 泛型 API 设计与性能
。
小结
这一节我们把泛型从「自由」推进到了「可控」:
- 裸泛型在函数体里几乎什么都不能做,
as any是逃避而非解决;extends用通用性换取了函数体内部的类型信息。 - 约束的本质是「可赋值性」,在结构化类型下按形状匹配,不需要显式
implements。 K extends keyof T配合返回类型T[K],能把键与值的类型绑定,实现参数联动的强签名。- 一个类型参数可以约束另一个(如
K extends keyof T),但不能循环引用。 - 多重约束用交叉类型
A & B;对象交叉是合并成员,原始类型交叉会退化成never。 - 约束管「允许什么」,默认值管「不写时用什么」,两者常一起出现。
- 约束只检查结构,不校验语义,运行时边界仍需校验。
约束规定了允许传入的类型,也可作为没有推断候选时的回退;默认值则能为省略类型参数的调用明确指定默认类型。下一节 8.2 泛型默认值与类型推断 会讲清默认值的求值顺序、推断的优先级规则,以及为什么 TypeScript 至今不支持「部分推断」。
阅读导航:上一节:7.3 类型守卫与控制流分析 · 下一节:8.2 泛型默认值与类型推断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。