《TypeScript编程入门》8.1 泛型约束(extends)

本节承接泛型函数入门,讲清为什么裸泛型在函数体里几乎什么都做不了,以及 extends 关键字如何把类型参数收窄到满足特定形状的类型。内容覆盖对象形状约束、keyof 与索引访问组合约束、一个类型参数约束另一个、交叉类型实现多重约束,并解析 does not satisfy the constraint 这类报错的成因与修法,帮助读者从能写泛型进阶到能设计泛型 API。

本节目标:读完这一节,你能解释「为什么裸泛型在函数体里几乎不能用」;能用 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 泛型默认值与类型推断 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes