本节目标:理解「类型参数」是什么,能读懂并写出
<T>(x: T) => T这样的泛型函数;掌握类型参数的自动推断规则与显式指定的时机;说清泛型与any的本质差别,知道为什么前者能在保留类型信息的同时实现复用。本节只讲函数上的泛型,接口与类上的泛型留到第 8 章。
4.3 泛型函数入门
上一节我们用重载解决了「同一个函数、不同参数组合」的问题。但重载有一个天花板:它要求你把每种参数类型都列出来。如果参数类型是「任意类型」,你不可能穷举——这时必须引入一个新工具:类型参数(type parameter)。
从 identity 函数说起
最简单的复用需求是「原样返回入参」。朴素做法是每种类型写一个函数——identityString、identityNumber、identityBoolean……三份代码逻辑完全相同,只是类型不同。用 any 能一行搞定,但代价是类型信息丢失:
function identityAny(value: any): any {
return value;
}
const s = identityAny("hello");
// s 的类型是 any —— 后面所有类型检查都失效了
s.toUpperCase(); // 不会报错,但也没有任何保障
any 的问题不在于「能通过编译」,而在于它把检查的责任推回给了你。更好的写法是让编译器记住「传进去什么,就返回什么」:
function identity<T>(value: T): T {
return value;
}
const s = identity("hello"); // s: string
const n = identity(42); // n: number
const arr = identity([1, 2, 3]); // arr: number[]
这里的 <T> 就是类型参数。它像函数参数一样,只不过接受的是类型而不是值。理解它的关键比喻是:
| 普通参数 | 类型参数 | |
|---|---|---|
| 声明位置 | function f(x: number) | function f<T>(x: T) |
| 传入时机 | 调用时传值 | 调用时传类型(或由编译器推断) |
| 作用范围 | 函数体内部 | 签名的所有类型位置 |
| 谁提供 | 调用方 | 调用方或编译器 |
类型参数是调用方的类型「占位符」——函数作者不知道具体是什么类型,但可以保证「输入和输出用同一个」。
泛型函数的语法
把泛型函数的语法拆开看:
function identity<T>(value: T): T {
// ↑ ↑
// 参数用 T 返回值也用 T
return value;
}
<T> 写在函数名之后、参数列表之前。T 只是一个名字,你可以叫它任何合法的标识符,但社区约定俗成:
T—— Type,最通用;K—— Key,键的类型;V—— Value,值的类型;E—— Element,元素类型;R—— Result,返回类型。
多个类型参数用逗号分隔:<T, K, V>。不要用单字母之外的命名去装深奥,但也不要为了简短牺牲可读性——像 TInput、TOutput 这种名字在复杂签名里反而更好。
箭头函数写泛型时有一个坑:.tsx 文件里 <T> 会被 JSX 解析器误认为是标签。解决办法是在类型参数后加一个逗号:
// 在 .ts 文件里没问题
const identity = <T>(value: T): T => value;
// 在 .tsx 文件里必须这样写,逗号是为了消除歧义
const identityTsx = <T,>(value: T): T => value;
这个逗号不影响语义,只是给解析器一个「这是类型参数列表,不是 JSX」的信号。用 React 的同学一定会遇到它。
类型参数推断:编译器帮你填
调用泛型函数时,你通常不需要写类型参数,编译器能从实参推断出来:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const a = first([1, 2, 3]); // T 推断为 number,返回 number | undefined
const b = first(["x", "y"]); // T 推断为 string
const c = first([]); // T 推断为 never,返回 never
注意第三个例子:空数组让 T 推断成了 never。这是合理的——既然数组里没有任何元素,元素类型就只能是「不可能存在的类型」。never 会向上传播,如果你后续要用 c,就得先处理这个情况。
推断的基本规则是:从实参到形参做「类型匹配」。看一个稍复杂的例子:
function pair<T>(a: T, b: T): [T, T] {
return [a, b];
}
pair(1, 2); // T = number
pair("a", "b"); // T = string
pair(1, "b"); // T = number | string —— 取候选类型的并集
最后一个例子值得注意:当同一个类型参数出现在多个位置、而实参类型不一致时,TypeScript 会取候选类型的联合,而不是报错。这是泛型推断里最容易被误解的一点。
显式指定类型参数
有时候推断结果不是你想要的,或者编译器根本推不出来。此时可以显式指定:
// 推断结果太窄:想要 (string | number)[] 而不是 string[]
const mixed = pair<string | number>("a", 1);
// 从实参完全推不出来
function createArray<T>(length: number, value: T): T[] {
return Array.from({ length }, () => value);
}
// 推断:T = number
const nums = createArray(3, 0); // number[]
// 显式指定,用于「实参推不出」或「想要更宽的类型」
const empty = createArray<string>(3, ""); // string[]
什么时候必须显式指定? 三个信号:
- 实参中没有能推断出类型参数的位置(比如
createArray(3)只传了长度); - 推断出的类型太窄,导致后续赋值失败;
- 你想让调用方(或读者)明确看到类型意图。
还要记住一点:TypeScript 不支持「部分推断 + 部分显式」——要么全部由编译器推断,要么把类型参数全部写出来。这是它与其他语言的一个差异。
泛型与 any 的本质差别
这是本节最重要的一节。看两个函数的对比:
// 版本 A:any
function firstAny(arr: any[]): any {
return arr[0];
}
// 版本 B:泛型
function firstGeneric<T>(arr: T[]): T | undefined {
return arr[0];
}
const users = [{ name: "Alice", age: 30 }];
const u1 = firstAny(users);
u1.name.toUpperCase(); // 能编译,但如果 arr 是数字数组就会运行时报错
const u2 = firstGeneric(users);
u2.name; // Error: 'u2' is possibly 'undefined'.
if (u2 !== undefined) {
u2.name.toUpperCase(); // OK
}
差别可以用一句话概括:any 是「放弃检查」,泛型是「延迟检查」。any 在定义时就放弃了信息,调用方拿到它之后做什么都不会被拦;泛型在定义时保留了一个「待定类型」T,调用时才由编译器确定,且这个精确类型由调用方享受。用一张表总结:
| 维度 | any | 泛型 <T> |
|---|---|---|
| 类型信息 | 丢失 | 保留并传递 |
| 调用方得到的类型 | any | 精确类型(如 string) |
| 拼写错误 | 不报错 | 报错 |
| 属性访问 | 任意属性都允许 | 只有 T 已知的属性允许 |
| 何时该用 | 迁移遗留 JS、类型确实不可知 | 绝大多数「通用」场景 |
还有一个关键区别:泛型函数体内部不能随意操作 T。
function bad<T>(value: T): number {
// Error: Property 'length' does not exist on type 'T'.
return value.length;
}
因为 T 可能是 number、boolean 等任何类型,编译器不允许你假设它有 length。这正是泛型的价值所在——它逼你说清楚「我需要 T 满足什么条件」。这个条件用 extends 表达,也就是泛型约束,我们在 8.1 泛型约束(extends)
里详细讲。
多个类型参数
当一个函数涉及两种以上的类型关系时,用多个类型参数:
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const lengths = map(["a", "bb", "ccc"], (s) => s.length);
// T = string, U = number → number[]
这里的 fn: (item: T) => U 是关键:它把「输入类型」和「输出类型」解耦了。注意 (s) => s.length 里 s 不需要写类型注解——它从 T 被推断为 string。
再看一个更实用的例子,实现「按键取值」:
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
interface Product {
id: number;
title: string;
price: number;
}
const products: Product[] = [
{ id: 1, title: "键盘", price: 299 },
{ id: 2, title: "鼠标", price: 99 },
];
const titles = pluck(products, "title"); // string[]
const prices = pluck(products, "price"); // number[]
// Error: Argument of type '"stock"' is not assignable to parameter of type
// '"id" | "title" | "price"'.
pluck(products, "stock");
K extends keyof T 是泛型约束的用法,它保证 key 必须是 T 的合法键,并且返回值类型 T[K] 精确到该键的值类型。这是「类型安全 + 复用」结合得最漂亮的一个例子,也是很多 ORM、状态管理库的核心签名。第 9 章会专门展开 keyof 与索引访问类型。
泛型在数组与对象上的常见模式
除了单个值,泛型更常用于「容器」:
// 1. 包装成统一响应结构
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
function ok<T>(data: T): ApiResponse<T> {
return { code: 0, data, message: "ok" };
}
const r1 = ok({ id: 1, name: "Alice" });
// ApiResponse<{ id: number; name: string }>
const r2 = ok([1, 2, 3]);
// ApiResponse<number[]>
注意 ApiResponse<T> 这种写法——T 出现在类型的位置,这就是泛型从「函数」延伸到「接口/类型别名」的入口。第 8 章会系统讲它,这里先建立直觉即可。
// 2. 分组:按回调返回的键把数组归类
function groupBy<T, K extends string>(items: T[], getKey: (item: T) => K): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of items) (result[getKey(item)] ??= []).push(item);
return result;
}
const grouped = groupBy(products, (p) => (p.price > 200 ? "expensive" : "cheap"));
// Record<"expensive" | "cheap", Product[]>
console.log(grouped.expensive.length); // 1
这里 K extends string 保证了 getKey 返回的能用作对象键,同时 Record<K, T[]> 让结果对象的键被精确推断为 "expensive" | "cheap"——访问 grouped.typo 会直接报错。
常见坑与排查
坑一:泛型参数只出现在返回值里。 这时编译器无法推断,只能显式指定:
// T 没有出现在任何形参中,调用时必然推断失败
function emptyArray<T>(): T[] {
return [];
}
const a = emptyArray(); // Error: Expected 1 type argument, but got 0.
const b = emptyArray<string>(); // 正确:显式指定,b 的类型是 string[]
坑二:把 T 用在「需要具体类型」的地方。
function stringify<T>(value: T): string {
// Error: This comparison appears to be unintentional because the types 'T'
// and 'string' have no overlap.
if (value === "hello") return value;
return JSON.stringify(value);
}
编译器认为 T 和 "hello" 没有重叠——因为 T 可以是任何类型。修复方式是加约束:<T extends string>。
坑三:误以为泛型能改变运行时行为。 泛型是纯类型层面的机制,编译后会被完全擦除:
function identity<T>(value: T): T {
return value;
}
// 编译产物(JavaScript)里 T 完全消失:
// function identity(value) { return value; }
因此你不能在运行时判断 T 是什么——下面这段代码会被直接拒绝:
function isString<T>(value: T): boolean {
// Error: 'T' only refers to a type, but is being used as a value here.
return typeof value === T;
}
需要运行时类型信息时,只能传入一个「类型守卫函数」或构造函数——这正是我们在 7.3 类型守卫与控制流分析 里讨论的模式。关于类型擦除带来的运行时盲区,3.3 any·unknown·never·void 与类型断言 也提到过,后续 13.1 节会专门展开。
坑四:过度泛型。 如果一个函数只会被一种类型调用,就不该泛型化:
// 过度设计:T 永远只会是 User
function getName<T extends { name: string }>(user: T): string {
return user.name;
}
// 更简单
function getName2(user: { name: string }): string {
return user.name;
}
判断标准:泛型只在「调用方需要保留具体类型」时才有价值。如果所有调用方得到的类型都一样,直接写具体类型更清晰。
泛型让「通用」不再等于「不安全」
回到本节的出发点。我们在 3.3 节见过 any 的便利与危险,在 4.2 节见过重载的精确与笨重。泛型恰好站在两者之间:
- 比重载更通用——不需要穷举类型;
- 比
any更安全——类型信息一路传递到调用方; - 代价是学习曲线——需要理解「类型参数」这个新概念,以及后面的约束、默认值、条件类型。
一个成熟判断:当你在签名里写下 any 时,先问一句「这里能不能用泛型」。多数情况下答案是能。这也是为什么像 Lodash、RxJS、TanStack Query 这类库的公开 API 几乎全是泛型签名——它们既要通用,又要让调用方拿到精确类型。如果你对真实项目里如何用泛型设计 API 感兴趣,可以延伸阅读本站专题文章 泛型 API 设计与性能
。
小结
这一节我们建立了泛型的第一层认知:
- 类型参数是类型的占位符。
<T>让函数作者可以「不知道具体类型」却仍能表达「输入输出是同一个类型」,这是any做不到的。 - 推断优先,显式补充。绝大多数调用不需要写类型参数;只有当实参推不出、推断太窄或需要表明意图时,才显式指定
<T>。注意 TypeScript 不支持部分推断。 - 泛型 ≠ any。
any放弃检查,泛型延迟检查并保留信息。泛型函数体内部不能随意操作T,这正是它逼你说清约束的地方——约束用extends表达,是第 8 章的主题。 - 泛型只在需要保留具体类型时才有价值。过度泛型会让签名更难读,判断标准是「调用方是否受益」。
到这里,第 4 章「函数与作用域」就完整了:4.1 讲了签名与参数的各种形态,4.2 讲了重载与 this 的类型行为,4.3 讲了泛型函数。三者合起来,你已经能写出对调用方友好、对实现方严格的函数签名。下一章 5.1 interface 定义对象结构
开始进入对象世界——从 interface 如何描述一个对象的结构讲起,而本节看到的 ApiResponse<T> 这类泛型接口,会在第 8 章集中展开。
阅读导航:上一节:4.2 重载、this 类型与箭头函数 · 下一节:5.1 interface 定义对象结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。