泛型就是类型参数。泛型函数声明一个占位类型,通常叫 T,每次调用时都会把它填上,所以输入的类型决定了输出的类型。这样一个函数就能同时处理数字、字符串和你自己的对象,而且每次调用依然有完整的类型。
函数名后面的 <T> 声明了类型参数。items: T[] 把它用在输入上,T | undefined 把它用在输出上,从而把两者关联起来。你从来没写过 number 或 string:TypeScript 根据实参推断出了 T。
为什么不用 any 或 unknown?
没有泛型的话,你会把参数类型写成 any 或 unknown。两者都能接受任何数组,但都丢掉了输入和输出之间的关联:
| 参数类型 | 接受任何输入 | 结果类型 | 是否检查 |
|---|---|---|---|
any | 是 | any | 否:错误也能通过编译 |
unknown | 是 | unknown | 是,但使用前必须收窄 |
T(泛型) | 是 | 与输入关联 | 是,而且类型精确 |
类型参数推断
通常 TypeScript 会根据你传入的值推断类型实参。你也可以在调用时用尖括号显式写出来;当实参中没有任何东西涉及 T,或者推断的结果不是你想要的时候,就需要这样做。
有两个推断结果值得了解。不显式写类型实参的 pair(1, "one") 会报错:TypeScript 根据第一个实参推断出 T = number,然后报告 Argument of type 'string' is not assignable to parameter of type 'number'.(TS2345)。而完全没有实参的 emptyList() 会得到 T = unknown,也就是 unknown[],这很少是你想要的。
多个类型参数
一个函数可以按需声明任意多个类型参数,用逗号分隔。每个参数都会被独立推断。
mapValues 一次推断出三样东西:K 是 "tea" | "cake",V 是 number,R 根据回调的返回值推断为 string。K extends string 是一个约束,下一页会讲到。
泛型接口和类型别名
类型也可以带参数。使用这个类型时要写上实参:Box<number>、ApiResponse<User>。大多数内置的集合类型都是这样的:Array<T>、Map<K, V>、Promise<T>、Record<K, V>。
Result<T, E = string> 还展示了默认类型参数:Result<number> 表示 Result<number, string>。和函数的可选参数一样,带默认值的参数必须放在必需的参数后面。
泛型类
类在名称后面接收类型参数,每个实例都会确定这些参数。字段、方法和构造函数的参数都可以使用它们。
如果构造函数接收一个 T,你就可以省略显式的类型实参:new Box(5) 会推断为 Box<number>。静态成员属于类本身而不是实例,所以不能使用类的类型参数(Static members cannot reference class type parameters.,TS2302)。
泛型箭头函数和函数类型
类型参数列表写在参数列表前面。同样的语法也用来描述泛型函数类型。
const last = <T>(items: T[]): T | undefined => items[items.length - 1];
type Mapper = <T, R>(items: T[], fn: (item: T) => R) => R[];
const mapAll: Mapper = (items, fn) => items.map(fn);
// In a .tsx file, <T> looks like a JSX tag. Add a trailing comma:
const lastTsx = <T,>(items: T[]) => items[items.length - 1];
用 const 类型参数推断字面量类型
默认情况下,泛型推断出的是放宽后的类型:pair("a", "b") 得到 [string, string]。当确切的值很重要时(路由名、事件名、列名列表),把参数标记为 const(TypeScript 5.0 及以上),它就会像实参写了 as const 一样进行推断:
调用者写的是普通数组,函数却保留了字面量类型。
什么时候不该用泛型
类型参数只有在关联两样东西时才有存在的价值:一个参数和返回类型、两个参数,或者一个参数和一个回调。如果 T 只出现一次,它就什么也没增加,直接写普通类型更清楚。
// Pointless: T is used once, so it is just a longer way to write unknown
function logValue<T>(value: T): void {
console.log(value);
}
// Clearer
function logValueSimple(value: unknown): void {
console.log(value);
}
// Also pointless: returns T but nothing connects T to an argument,
// so the caller is really just asserting a type
function parseJson<T>(text: string): T {
return JSON.parse(text);
}
最后一种写法很常见,也很有误导性:parseJson<User>(text) 看起来类型安全,实际上什么都没检查,和 JSON.parse(text) as User 完全一样。应该返回 unknown 再进行校验。其他滥用的迹象还有:一个类型参数总是被传入同一个实参,或者它可以用两个已知类型的联合来代替。
如果泛型需要知道 T 的某些信息(比如它有 length,或者 K 是 T 的键),下一步就是用 extends 添加约束。
常见问题
TypeScript 中的泛型是什么?
泛型就是类型参数:像 T 这样的占位符,每次使用函数、接口、类型别名或类时,都会用一个真实的类型填进去。function first<T>(items: T[]): T | undefined 适用于任何数组,结果的类型就是你传入数组的元素类型,所以 first([1, 2]) 是 number | undefined,first(["a"]) 是 string | undefined。
泛型和 any 有什么区别?
any 会关闭类型检查:传入的值和返回的值之间没有任何关联,结果也是 any。泛型则保留了这种关联:identity<T>(x: T): T 返回的正是你传入的类型。如果你接受任何值、但不需要把类型传回去,就用 unknown;需要传回去,就用泛型。
TypeScript 中的 <T> 是什么意思?
它声明了一个名为 T 的类型参数。在 function wrap<T>(value: T) 中,T 是一个类型变量,每次调用时 TypeScript 都会根据实参把它填上,你也可以像 wrap<string>("a") 这样显式传入。T 这个名字只是惯例,TItem 或 Key 这样更长的名字效果完全一样。
TypeScript 中如何写泛型箭头函数?
把类型参数列表放在参数列表前面:const first = <T>(items: T[]): T | undefined => items[0];。在 .tsx 文件中,<T> 会被当成 JSX 标签,所以要写成带尾逗号的 <T,>,或者写成 <T extends unknown>。
TypeScript 中如何给泛型设置默认类型?
在参数后面加上 = Type:interface ApiResponse<T = unknown> { data: T }。这样不带类型实参的 ApiResponse 就表示 ApiResponse<unknown>。和函数的可选参数一样,带默认值的类型参数必须放在没有默认值的参数后面。