泛型基础
本教程共 80 篇 · 第 38 篇 · 更新于 2026-08-10 · 约 12 分钟阅读
本节目标:从”为什么要泛型”出发,掌握类型变量的概念,学会定义泛型函数类型和泛型接口,初步接触泛型类。
第 22 章我们写了泛型函数——你知道怎么用 <T> 让输入输出的类型绑定在一起。但泛型远不止函数这一件事。它是 TypeScript 类型系统里最核心的抽象工具之一,贯穿接口、类、类型别名,甚至后面要讲的条件类型和映射类型。这一章我们站在更高的地方,重新审视泛型的”地基”。
为什么需要泛型:any 做不到的事
假设你要写一个函数,它能接收一个数组,返回这个数组的第一个元素。不用泛型的话,你可以这样写:
function first(arr: any[]): any {
return arr[0];
}
const val = first([1, 2, 3]);
// val 的类型是 any 😞
返回 any 的问题在于——你丢失了 type safety。val 是 any,它可以赋给一个 string 变量而不报错,但运行时它其实是个 number。编译器眼睁睁看着你犯错,却什么都做不了。
另一个思路是为每种类型都写一个版本:
function firstNumber(arr: number[]): number { return arr[0]; }
function firstString(arr: string[]): string { return arr[0]; }
// ……以后每多一种类型,都要复制一份
代码重复到令人窒息。而且你很难提前知道未来会有哪些类型。
泛型就是来解决这个矛盾的——既能处理多种类型,又不会丢掉类型信息。
类型变量:用一个字母代替整个类型
泛型的核心概念是类型变量。它跟普通变量的逻辑一样,只不过普通变量存的是”值”,类型变量存的是”类型”。
// 值变量:存的是具体的数据
let value = "hello";
// 类型变量:存的是具体的类型
function identity<T>(arg: T): T {
return arg;
}
<T> 声明了一个类型变量 T。调用 identity("hello") 时,TypeScript 推断 T = string,于是整个函数签名变成了 (arg: string): string。类型变量让签名不再是写死的,而是”模板化的”——你给什么类型,它就变成对应的签名。
你可以把 T 想象成一个占位符:写代码的时候你不知道它是什么,但 TypeScript 在调用处帮你”填”进去。
命名惯例
单一类型参数习惯叫 T,多个参数依次用 T、U、V。如果参数有明确语义,用描述性名称更好理解:
// 简单场景:T 就够了
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// 多个参数时:TKey、TValue 比 T、U 更清晰
type Dictionary<TKey, TValue> = {
[key: TKey]: TValue;
};
习惯上以 T 为前缀,因为泛型在其他语言里也叫 template。
泛型不仅仅属于函数
泛型可以附着在很多地方。用泛型函数的写法你已经会了:
function identity<T>(arg: T): T {
return arg;
}
那把 identity 的类型单独抽出来会是什么?
泛型函数类型
跟普通函数类型写法类似,只不过前面多了 <T>:
let myIdentity: <T>(arg: T) => T = identity;
// 类型参数的名字无所谓,叫 Input 也行
let myIdentity2: <Input>(arg: Input) => Input = identity;
关键点是:类型参数的位置和个数要对上,名字可以随便取。
如果用对象字面量形式的调用签名,也是一样的:
let myIdentity: { <T>(arg: T): T } = identity;
泛型接口
把上面那句的类型往外提到接口上:
interface GenericIdentityFn<T> {
(arg: T): T;
}
function identity<T>(arg: T): T {
return arg;
}
// 使用时需要指定 T 的具体类型
let stringIdentity: GenericIdentityFn<string> = identity;
// stringIdentity 现在是 (arg: string) => string
注意区分两种写法的差别:
- 类型参数在调用签名上(
<T>(arg: T): T):这个接口描述的是一个泛型函数本身。 - 类型参数在接口上(
GenericIdentityFn<T>):接口本身是泛型的,使用时锁定类型。
这两种写法对应不同的需求。前一种适合泛型回调函数,后一种适合你想在类型层面就绑定好特定类型的场景。
泛型类:一个队列的例子
泛型类就是在类名后面加 <T>,让类的整个实例共享同一个类型参数。经典例子:实现一个泛型栈(Stack)。
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
// 数字栈
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
// numberStack.push("3"); // ❌ 类型错误
// 字符串栈
const stringStack = new Stack<string>();
stringStack.push("hello");
// 从中取出的元素类型自动是 T
const num = numberStack.pop(); // number | undefined
const str = stringStack.pop(); // string | undefined
一个 Stack<T> 管所有类型,不用为每种类型重复写一遍代码。而且类型安全:推入数字的栈,弹出的也一定是数字。
Note泛型类的静态成员不能使用类的类型参数。因为静态成员属于类本身,不属于实例——它在所有实例之间共享,无法绑定到某一个具体的
T。
类型参数推断
大多数时候你不需要显式写 <>,TypeScript 会从参数推断:
const stack = new Stack<number>(); // 显式指定
// 若 Stack 有接受数组的构造函数(如 constructor(initial: T[])),也可用 new Stack([1, 2, 3]) 自动推断 T = number
函数同理:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // 推断 T = number,返回值 number | undefined
const str = first(["a", "b"]); // 推断 T = string,返回值 string | undefined
如果推断不出来或者你想强制覆盖,才需要显式写 <类型>。
泛型不是银弹
泛型虽好,也别滥用。有些场景你不需要泛型,用联合类型就够了:
// 这种写法没必要——T 只用了一次,跟用 any 加断言区别不大
function log<T>(value: T): T {
console.log(value);
return value;
}
// 还不如直接用参数类型
function log(value: unknown): void {
console.log(value);
}
泛型的价值在于它建立类型之间的关联。如果类型参数只在一个位置出现,泛型就退化成了”花哨的 any”,意义不大。
小结
泛型不是什么魔法——它就是让你用类型变量来建立类型之间的对应关系。一个 <T> 管输入输出,让函数、接口、类在保持类型安全的同时具备复用性。核心公式就一句话:类型变量是模板,调用时填入具体类型,TypeScript 帮你推导。 下一章我们聊聊怎么给类型变量加约束条件——让它不能接受”所有类型”,而只接受”满足某种条件的类型”。