1. 你以前怎么惨
你写了一个函数,接收一个值,把它包进数组里返回。
functionwrapInArray(value:string):string[]{return[value];}隔壁同事也要用。但他传进来的是number。你复制了一份:
functionwrapNumberInArray(value:number):number[]{return[value];}老板又要一个boolean版本。你开始复制第三份。HR 找你谈话,说代码审查没过。
问题不是同事传错了类型。问题是你的函数写死了类型。类型不安全的时候,你只能复制粘贴。类型安全了,你又陷入了复制粘贴的另一种形式——只不过这次编译器逼你复制。
泛型解决这个问题:写一个函数,让它自动适应传给它的类型。
2. 先看一眼烂代码
// 三个函数,逻辑一模一样,只是类型标注不同functionwrapString(value:string):string[]{return[value];}functionwrapNumber(value:number):number[]{return[value];}functionwrapBoolean(value:boolean):boolean[]{return[value];}// 更糟糕的:用 any 逃避functionwrapAny(value:any):any[]{return[value];}// any 让你输掉所有类型信息:constresult=wrapAny("hello");// result 的类型是 any[]。TS 不知道里面是 string。// result[0].toUpperCase() —— 编译通过,运行也通过(因为是 string)// result[0].toFixed(2) —— 编译也通过!因为 any 跳过了所有检查// 运行时崩溃:toFixed is not a function用any就像在超市里把所有人的购物车混在一起——你不知道谁的购物车里有鸡蛋,谁的有砖头。出问题了才发现。
3. 用新语法修好它
3.1 泛型函数:<T>
泛型类比自动售货机。投可乐进去,出来可乐。投薯片进去,出来薯片。售货机不关心你投什么,但它保证出来的东西和你投的一样。
// <T> 声明一个类型变量 T。调用时,TS 根据你传入的值推导出 T 是什么。functionwrapInArray<T>(value:T):T[]{return[value];}conststrings=wrapInArray("hello");// strings: string[]constnumbers=wrapInArray(42);// numbers: number[]constbooleans=wrapInArray(true);// booleans: boolean[]// TS 记住了入参类型和返回类型的对应关系。// strings[0].toUpperCase() ✅// strings[0].toFixed(2) ❌ 编译报错T只是一个名字。叫TValue、叫Item、随便你。当函数只有一个泛型参数时,惯例用T。
你也能显式指定类型参数:
constexplicit=wrapInArray<string>("hello");但绝大多数时候没必要——TS 能自己推导出来。
3.2 泛型约束:<T extends ...>
有时你不接受任意类型。你只接受"有某个属性"的类型。
// 需求:接收一个对象,返回它的某个属性值// 不约束的话,TS 不知道 T 有没有 length 属性functiongetLength<T>(obj:T):number{returnobj.length;// ❌ Property 'length' does not exist on type 'T'}用extends约束 T:
// T extends { length: number } —— T 必须是"有 length 属性的类型"functiongetLength<Textends{length:number}>(obj:T):number{returnobj.length;// ✅ TS 知道 obj 一定有 length}getLength("hello");// ✅ string 有 lengthgetLength([1,2,3]);// ✅ 数组有 lengthgetLength({length:10});// ✅// getLength(42); // ❌ number 没有 length泛型约束 = “这台售货机只接受饮料,不接受石头”。
一个更实用的例子:
// 确保传入的对象至少有 name 字段functiongreet<Textends{name:string}>(obj:T):string{return`Hello,${obj.name}!`;}greet({name:"Alice",age:30});// ✅ 多出来的属性没关系// greet({ age: 30 }); // ❌ 没有 name3.3 泛型接口
接口也可以用泛型。标准库的Array<T>和Map<K, V>就是泛型接口。
// 一个简单的"键值对"容器interfaceBox<T>{value:T;}conststringBox:Box<string>={value:"hello"};constnumberBox:Box<number>={value:42};// 更实用的例子:API 响应包装interfaceApiResponse<T>{data:T;status:number;message:string;}typeUserResponse=ApiResponse<{id:number;name:string}>;typePostResponse=ApiResponse<{title:string;body:string}>;// 标准库示例constnames:Array<string>=["Alice","Bob"];constscores:Map<string,number>=newMap();scores.set("Alice",95);3.4 默认泛型参数
有些泛型参数 90% 的情况都是同一个类型。给个默认值。
// 大多数时候,事件数据就是一个简单的字符串interfaceEvent<T=string>{type:string;data:T;}constsimple:Event={type:"click",data:"clicked"};// data: stringconstcomplex:Event<{x:number}>={type:"move",data:{x:100}};// data: { x: number }默认参数让你不写<string>也能得到默认行为,但需要时还能覆盖。
4. 什么时候不该用
函数只接受一个具体类型。别写成
function add<T>(a: T, b: T)再在内部硬转成 number。直接写function add(a: number, b: number)。泛型参数没有出现在参数列表中。
function create<T>(): T——你能凭空造出一个 T 吗?不能。那就在骗 TS。这种几乎总是反模式。用泛型替换简单的联合类型。如果你知道参数只可能是
string | number,写联合类型比泛型更清晰。为了"酷"而泛型。每个类型参数都增加认知负担。如果一个问题没有重复的类型逻辑,不需要泛型。
5. 小结
| 语法 | 一句话 | 示例 |
|---|---|---|
<T> | 声明类型变量,输入和输出类型联动 | function id<T>(x: T): T { return x; } |
<T extends U> | 约束 T 必须是 U 的子类型 | function len<T extends { length: number }>(x: T): number |
Interface<T> | 接口接受类型参数 | interface Box<T> { value: T } |
<T = U> | 泛型默认值 | interface Event<T = string> { data: T } |
Array<T> | 标准库的泛型数组 | const a: Array<number> = [1, 2] |
Map<K, V> | 标准库的泛型 Map | const m: Map<string, number> = new Map() |