Skip to content
动机:没有约束的时候
给函数加上类型参数之后,代码获得了“先不决定类型,但保留类型关系”的能力。但也带来一个直接的限制:在没有约束的情况下,函数体内只能把类型参数当作“任意可能的类型”对待,不能假设它拥有任何具体的属性或方法。
以一个记录日志的函数为例:
typescript
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
// ~~~~~~
// Property 'length' does not exist on type 'Type'.
return arg;
}编译器报错,因为 Type 可以是 number、boolean,也可以是根本没有 length 的类型。TypeScript 无法在定义泛型函数时保证调用方传入的每种类型都恰好有 length 属性,所以直接拒绝访问 arg.length。
这时候泛型参数能做的操作约等于 unknown:只能赋值、传递和返回,不能读取属性、不能调用方法。要安全地使用特定成员,就得告诉编译器“Type 至少应该具备什么结构”。
extends 基础约束
通过 extends 可以为类型参数划出一条底线——传入的实际类型必须兼容这条底线所描述的结构。
typescript
interface Lengthwise {
length: number;
}
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // 安全
return arg;
}Type extends Lengthwise 表示这个类型参数不能是任意类型,它必须满足 Lengthwise 的形状——拥有一个类型为 number 的 length 属性。函数体内可以访问 arg.length,因为约束已经保证了这一点。
调用方的行为也会随之改变:
typescript
loggingIdentity("hello"); // OK
loggingIdentity([1, 2, 3]); // OK
loggingIdentity({ length: 10 }); // OK
loggingIdentity(100);
// ~~~
// Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.约束不一定需要具名接口,内联的对象结构同样有效:
typescript
function logLength<T extends { length: number }>(item: T): T {
console.log(item.length);
return item;
}T 的底线就是一个带 length: number 的对象。数组、字符串、arguments 类数组对象以及一切满足这个形状的值都能传入,数字、布尔值这类没有 length 的类型会被编译器拦截。
关键点:约束告诉编译器类型参数至少有哪些成员,而不是精确指定类型。类型参数仍然是泛型的,只是多了一层结构上的最低门槛。
keyof 操作符
在深入索引约束之前,先看 keyof。它接收一个对象类型,返回该对象所有键构成的字符串字面量联合类型。
typescript
type Point = { x: number; y: number };
type Keys = keyof Point; // "x" | "y"
const key: Keys = "x"; // OK
// const other: Keys = "z"; // 错误对内置类型同样适用:
typescript
type ArrayKeys = keyof string[];
// "length" | "push" | "pop" | ... 以及 numberkeyof 最常见的用途是和泛型搭配,保证访问属性的键是合法的。
索引类型约束
把 keyof 放进类型参数的约束位置,得到索引类型约束。最典型的形式是安全读取对象属性:
typescript
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}K extends keyof T 明确了两件事:
K必须是T的键之一- 返回值类型精确为
T[K],不是any或宽泛的联合类型
调用表现:
typescript
const person = { name: "Alice", age: 30 };
const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
getProperty(person, "email");
// ~~~~~~~
// Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.传 "email" 会在编译期被拦截。在深层嵌套配置对象中查找属性,或在事件系统中通过属性名分发事件时,这类约束能消除掉一大批拼写错误。
同一模式也适用于泛型组合。例如合并两个对象时,用约束控制源对象形状:
typescript
function merge<A extends object, B extends object>(a: A, b: B): A & B {
return { ...a, ...b };
}extends object 阻止了原始类型(number、string 等)的意外传入,因为展开操作只对对象有意义。约束划出的不是具体能力,而是合法使用的前提。
默认类型参数
泛型有时需要为类型参数提供后备方案:当调用方既没有显式指定类型,TypeScript 又无法从参数中推断时,用默认值填补。
默认类型参数写在 = 之后:
typescript
function createArray<T = string>(): T[] {
return [];
}<T = string> 表示:如果类型推断失败,假定 T 是 string。
调用时:
typescript
const arr1 = createArray(); // T 取默认值 string,arr1 为 string[]
const arr2 = createArray<number>(); // 显式指定,arr2 为 number[]能从参数推断时,默认值不会触发:
typescript
function wrapValue<T = string>(value: T) {
return { value };
}
const r1 = wrapValue("hello"); // T 推断为 string(推断结果,不是默认值)
const r2 = wrapValue(42); // T 推断为 number(默认值被忽略)默认类型参数只在编译器没有其他线索确定类型参数时生效。一旦可以从参数或上下文推断出来,推断结果覆盖默认值。
约束与默认值同时使用
约束和默认值经常一起出现。两条规则:
- 默认值写在约束之后:
<T extends Constraint = Default> - 默认类型本身必须满足约束
typescript
interface HasLength {
length: number;
}
function printLength<T extends HasLength = { length: 0 }>(item: T): T {
console.log(item.length);
return item;
}{ length: 0 } 满足 HasLength 约束,可以作为默认值。写成 <T extends HasLength = string> 也可以,string 有 length 属性且类型为 number,同样满足约束。核心要求是默认类型必须兼容。
顺序写错会导致语法错误:
typescript
// function bad<T = string extends HasLength>(item: T) { ... }
// ~~~~ 语法错误泛型接口中的应用
约束和默认类型参数也适用于接口声明。以事件接口为例:
typescript
interface Event<T extends EventTarget = EventTarget> {
target: T;
type: string;
}T 必须是一个 EventTarget,默认值也是 EventTarget。大部分简单场景下可以直接写 Event,不需要每次指定类型:
typescript
const genericEvent: Event = {
target: new EventTarget(),
type: "click"
};需要更具体的 target 类型时显式传入:
typescript
interface MyCustomTarget extends EventTarget {
detail: string;
}
const customEvent: Event<MyCustomTarget> = {
target: { detail: "data", addEventListener() {} } as MyCustomTarget,
type: "custom"
};这种模式在 DOM 事件系统、消息总线、API 响应封装中都能见到。接口带上约束与默认值后,同时具备了多数场景下开箱即用的便利和需要时深度定制的类型安全。
常见误区
约束没有赋予额外的能力
约束只告诉编译器“至少有什么”,不是“只有这些”。在 T extends { length: number } 的函数体内,只能安全访问 length,不能假设它有 name、push 等属性。额外的属性访问会导致编译错误,除非再加上新的约束收窄。
默认值不等于强制默认
声明 <T = string> 之后 T 并不会永远是 string。只要调用时 TypeScript 能从参数推断出 T,推断结果就会覆盖默认值。默认值只是备选。
keyof 的结果不是纯粹的键名
对数组使用 keyof,除了得到 "length"、"push" 等字符串键,还会得到 number。这是因为数组接受数字索引。如果只用结果做对象属性名,需要留意这个差异。
约束过窄会削弱通用性
添加约束后,函数能接收的类型范围收窄了。每加一层约束,通用性就下降一分。取舍在于:给了约束,函数体内表达能力更强,但调用方需要传更具体的类型;不给约束,函数通用性最大化,但内部只能处理极少的通用操作。这需要根据具体的 API 面来决定。
参考链接
- [1] https://www.typescriptlang.org/docs/handbook/2/generics.html
- [3] https://www.typescriptlang.org/docs/handbook/2/keyof-type-operator.html
- [5] https://www.typescriptlang.org/zh/docs/handbook/2/conditional-types.html
- [6] https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-3.html#generic-parameter-defaults
- [7] https://learn.microsoft.com/zh-cn/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters
- [8] https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/where-generic-type-constraint
