写 TypeScript 这几年,TypeScript Generics 泛型是我花时间最长才真正理解的部分,也是后来收获最大的一部分。早期写工具函数或者封装库的时候,我最常用的手段就是 any,偶尔兴致来了把 any 换成 unknown,结果类型报错从“写码时消失了”变成“调用时全冒出来”。直到我在一个内部项目里维护一个通用的请求方法,才被逼着把 TypeScript Generics 泛型认真啃了一遍。
这篇文章不打算复述官方文档,我想从一个普通开发者的视角,把泛型要解决的问题、常用写法的底层逻辑、真实项目里的封装姿势讲清楚,顺带把我踩过的坑列出来。适合刚接触泛型觉得晦涩的同学,也适合用了很久但总是“看着不难受、自己写不顺手”的读者。泛型看起来不过是一对尖括号和一堆 T、K、V,但它是类型系统里真正具备“计算能力”的部分,理解了泛型,你对 TypeScript 的理解会从“给变量标注类型”提升到“用类型写程序”的层次。每个章节我都会给出可以复制到项目里验证的代码,需要对比的地方用表格说明。
1. 从一个实际报错开始:没有泛型时我们怎么办
1.1 一个返回“第一个元素”的函数
先看一个非常常见的场景:实现一个工具函数,返回一个数组的第一个元素。如果不用泛型,大体上有三种选择:给参数和返回都标注 any、把数组类型写成联合类型、或者用函数重载。前两种代码写出来长这样:
// 方案一:any 大法 function first(arr: any[]): any { return arr[0]; } const num = first([1, 2, 3]); // num: any,调用时没有任何提示 const str = first(['a', 'b']); // str: any // 方案二:联合类型,只覆盖少数类型 function firstCasted(arr: (string | number | boolean)[]): string | number | boolean { return arr[0]; }第一种写法的问题在于调用方拿到的全是 any,类型保护形同虚设;第二种写法稍微好一点,但搞出一个包含所有可能类型的联合类型,不仅繁琐,而且每次新增类型都要改函数签名。函数重载也不太适合这个场景——你要为每一种类型组合写签名,类型一多代码直接爆炸。
还有一个候选是 unknown,很多人以为 unknown 比 any 安全,但当返回类型是 unknown 时,调用方依然需要手动断言才能使用返回值,这本质上把责任推给了每一处调用点。泛型则把责任放在“类型推导”上:只要调用方传入了数组,编译器就会自动把 T 填上。所以对比 any、unknown、联合类型、重载这四种方案,泛型在“保留程序意图”上是最清晰的。
1.2 泛型的写法只是把“类型”变成“参数”
用泛型改写后,同一个 first 函数长这样:
function first<T>(arr: T[]): T | undefined { return arr[0]; } const num = first([1, 2, 3]); // num: number const str = first(['a', 'b']); // str: string const mixed = first([1, 'x']); // mixed: number | string这里的<T>可以理解成一个“类型参数”,在调用的时候被填入实际的类型。编译器会从传入的数组元素自动推断 T,推断出来的类型就是函数的返回值类型。调用方不需要写任何显式类型标注,不需要任何 cast,类型保护完全保留。
我第一次看到这种写法时,最迷惑的是 T 到底从哪里来、又去了哪里。后来换了一种理解方式:把泛型当成一个“类型层面的函数”,<T>是它的参数列表,调用时传入类型,返回值装饰到具体的函数、接口或类上。这个类比几乎贯穿后面所有对泛型复杂特性的理解,先记住它,第三节的 infer 和条件类型会更依赖这个视角。
2. 从基础语法到约束:泛型在函数、接口、类中的几种姿态
2.1 函数的泛型:推断、显式指定与多个参数
最基本的函数泛型是单参数,多参数的情况也很常见,比如把两个不同类型的值打包成一个元组:
function pair<A, B>(a: A, b: B): [A, B] { return [a, b]; } const p1 = pair(1, 'one'); // 类型自动推断为 [number, string] const p2 = pair<string, number>('no', 2); // 也可以显式指定显式指定的场景多半出现在类型推断“猜不中”的时候。举个例子,如果一个函数希望参数是一个具体的字面量类型,但你又不想用as const以外的方式限制它,这时候显式类型参数可以帮你。还有一些场景涉及泛型默认值,后面会谈到。
有一个细节必须提醒:在.tsx文件里,function first<T>这种函数声明没有解析问题,但箭头函数写成<T>(arr: T[]) => ...就会出问题,因为 JSX 解析器会把<T>当成一个 JSX 标签。标准写法是在尖括号后面加一个逗号:<T,>(arr: T[]) => ...。很多团队直接在项目里统一启用“箭头函数 + 泛型都加逗号”的规范,避免心智负担,这是新手最容易忽视的报错来源。
2.2 接口和类型别名中的泛型:让类型结构可复用
接口里也可以用泛型,典型如把某个字段的类型作为参数:
interface ApiResponse<T> { code: number; message: string; data: T; } type User = { id: number; name: string }; const res: ApiResponse<User> = { code: 0, message: 'ok', data: { id: 1, name: 'foo' }, };这个 ApiResponse 在真实项目里几乎每个团队都会写一份。要理解的是:接口在使用时才传入 T,就像函数在使用时才传入参数。它描述的是一个“骨架”,具体字段类型可以由使用方决定。这样一套响应结构就能适配任意 data 形态,而不再需要为每个实体类型单独写一个 Response 接口。
类型别名里同样支持泛型,写法和接口几乎一致。区别主要在于:type 可以表示联合类型、条件类型这类“值不是普通对象结构”的类型,interface 描述对象形状更自然。两者绝大多数场景选一个用就行,不必纠结。
类的泛型也类似,在类的名字后加<T>,然后这个参数可以在成员类型、方法参数和返回值中使用。例如一个比较简单的状态容器:
class StateHolder<T> { private state: T; constructor(initial: T) { this.state = initial; } get(): T { return this.state; } set(next: T): void { this.state = next; } }这个 StateHolder 实例化的时候一旦确定 T,get 和 set 的类型就都确定了。类泛型在使用上最大的坑是:如果你在类的方法里希望泛型方法能“临时引入”另一个独立的类型参数,那么这个类型参数属于方法而不是类,两者不能混用。这是早期我想不通的一个点,后面踩坑部分会详细展开。
2.3 泛型约束 extends 与 keyof:给类型参数加范围
类型参数如果完全不设限制,那就什么都传得进来。真实业务里经常需要限定“传入的类型必须满足某个条件”,比如必须是一个对象、必须包含某个字段、必须是 number 或 string 之一。这就是extends约束:
function getLength<T extends { length: number }>(value: T): number { return value.length; } getLength('hello'); // 可以,string 有 length getLength([1, 2, 3]); // 可以,数组有 length getLength(123); // 报错,number 没有 length这里的 extends 不是“继承”,而是“必须要满足、必须兼容于”。你可以把T extends { length: number }读成“T 必须是一个拥有 length 属性且该属性是 number 的类型”。约束让泛型从完全未知变成“在一个形状范围内未知”,编译器才允许你在函数体里访问 length 属性,否则 TS 会认为你在操作一个不确定的未知类型。
约束和 keyof 经常结合在一起。keyof 可以把一个对象类型的键取出来变成联合类型,配合泛型可以实现“传入字段名必须是对象真实键名”的效果:
function getProp<T extends object, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: 1, name: 'foo' }; getProp(user, 'id'); // number getProp(user, 'name'); // string // getProp(user, 'age'); // 报错:age 不在 keyof user 里这个模式的收益是双倍的:K 被约束为对象 T 的真实键,返回值 T[K] 会精确跟随具体键对应的字段类型。索引访问类型 + keyof + extends 的组合几乎是所有表单、状态、数据映射工具库的基石,后面看到 Pick、Record、对象属性遍历映射,底层都是这一套东西。
3. 条件类型和 infer:当泛型开始“计算”
3.1 条件类型:类型的世界里也有 if-else
泛型不只是“把类型传进去再原样拿出来”,它还能根据传入的类型做判断,这就是条件类型。语法很直观:
type IsArray<T> = T extends any[] ? true : false; type A = IsArray<string[]>; // true type B = IsArray<number>; // false你可以把它读成一段类型层面的 if-else。真正的复杂度隐藏在“分配率”里:当 T 是一个联合类型时,条件类型会对联合类型的每个成员分别执行判断,最后把结果组成新的联合类型。
type Diff<T, U> = T extends U ? never : T; // Diff<'a' | 'b' | 'c', 'a' | 'b'> 的结果是 'c' // 过程:'a' extends 'a'|'b' ? never : 'a' -> never // 'b' extends 'a'|'b' ? never : 'b' -> never // 'c' extends 'a'|'b' ? never : 'c' -> 'c'最后 never 在联合类型里会被自动剔除。这个 Diff 其实就是内置 Exclude 的实现。理解分配律非常重要,因为你在写工具类型的时候,经常发现“结果和自己想的不一样”,十有八九是忘了条件类型面对联合类型会逐个分发。想要关闭分发,可以把类型参数包在一个方括号里:[T] extends [U],这样条件判断针对的是整个联合类型而不是它的成员。
3.2 infer:从传入类型里“猜出”一部分类型
infer 是泛型里最神奇的关键字。它只能出现在条件类型的 true 分支里,作用是从一个类型结构中提取出一个子类型。最经典的例子是自己实现 ReturnType:
type MyReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never; function fetchUser() { return { id: 1, name: 'foo' }; } type User = MyReturnType<typeof fetchUser>; // { id: number; name: string }infer R相当于告诉 TypeScript:不要去管参数具体是什么,帮我看看返回值到底是什么类型,然后把这个类型存到 R 里。infer 常常被拿来和“模式匹配”做类比,就像正则里的捕获分组——你不需要把整个字符串写死,只需要声明“这个位置会出现一段内容,把它捕获出来”。
类似的,Parameters 用 infer 提取参数元组类型,数组的元素类型可以用T extends (infer U)[] ? U : never提取。infer 的捕获范围非常灵活,你在官方工具类型里见到的绝大多数“提取类型”能力,底层都是 infer 在起作用。
3.3 typeof 和 keyof 在类型推导中的配合
还有一个使用频率很高、很容易被忽略的配合:typeof 可以拿到“一个值”的类型,然后用 keyof 取出它的键,再配合泛型做约束。例如需要“根据某个对象的键批量生成状态”的场景:
const initialCounters = { count: 0, step: 1, limit: 100, }; type CounterKeys = keyof typeof initialCounters; // 'count' | 'step' | 'limit'typeof 在这里不是运行时运算符,它在类型位置上出现时,是 TS 的“值转类型”语法。真实项目里经常会遇到:一个常量配置对象定义好了,希望另一个类型结构自动跟随它的键,这就是最省心的做法。配合同样来自对象的值类型(typeof initialCounters[keyof typeof initialCounters]),可以让类型跟随数据变化,减少大量手工维护重复类型的成本。
4. 把内置工具类型拆开看:每个都是教科书级的泛型案例
4.1 映射类型的基础:Partial、Required、Readonly
TypeScript 自带了一套工具类型,官方文档对它们的解释往往是“把属性变成可选/只读”。但太多人只会用,说不出原理,遇到需求变化就懵。其实这几个工具类型就是“映射类型”三板斧的组合:keyof 取键、in 遍历、属性修饰符加减。
type MyPartial<T> = { [K in keyof T]?: T[K] }; type MyRequired<T> = { [K in keyof T]-?: T[K] }; type MyReadonly<T> = { readonly [K in keyof T]: T[K] };[K in keyof T]可以被理解成 for-in 循环:遍历 T 的每一个键,然后逐个声明新类型的属性。?和readonly是属性修饰符,-?表示移除可选标记,-readonly表示移除只读标记。理解这一层之后,你遇到“把所有属性变成可选但保留个别必选”这类需求时,就能基于内置类型组合去写,而不是去网上抄一段不明所以的代码。
4.2 Pick 与 Record:对象结构的选择与构造
再看两个方向相反的工具类型。Pick 是从一个对象类型里挑出若干键,Record 是从一个键集合构造一个对象类型:
type MyPick<T, K extends keyof T> = { [P in K]: T[P] }; type MyRecord<K extends keyof any, V> = { [P in K]: V };MyPick 很容易理解:遍历 K(K 被约束为 T 的键),把对应的属性原样保留。MyRecord 则是把“键集合”映射成“统一值的对象”,比如定义一个枚举到文案的映射表:
type Status = 'pending' | 'done' | 'failed'; const statusText: Record<Status, string> = { pending: '等待中', done: '已完成', failed: '失败', };注意这里的keyof any其实就是string | number | symbol,它是 TypeScript 内置的“所有可作为对象键的类型”集合。Record 的 K 被限制成一个标准的键类型,可以防止传入了完全不能做键的类型。
4.3 条件工具类型:Exclude、Extract、NonNullable
条件类型工具在业务型工具类型里也很常见。Exclude 前面讲过,就是“从联合类型里剔除一部分”。Extract 反过来,保留两个联合类型的交集:
type MyExclude<T, U> = T extends U ? never : T; type MyExtract<T, U> = T extends U ? T : never; type MyNonNullable<T> = T extends null | undefined ? never : T;联合类型 + 条件类型的分配律会反复出现,所以建议把它当作默认心态:看到T extends U ? X : Y这种写法,第一反应是“T 如果是联合类型,会被拆开处理”。这三个工具类型在枚举过滤、空值剔除场景里非常实用,尤其 NonNullable 配合接口字段做清洗,能省掉一堆重复的类型守卫。
4.4 ReturnType、Parameters:函数类型上的元编程
最后看一组和函数相关的工具类型。它们内部都用到了 infer:
type MyReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any; type MyParameters<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;这里有一个容易踩的细节:约束里用的是any[],它表示“任意参数列表”;条件类型里也用同样的结构去匹配,infer P 捕获的是参数的元组类型,所以返回的是[string, number]这类元组。写库的同事会大量使用这两个工具类型,把某个函数的返回值取出来作为另一个函数的类型,这就是“类型层面的依赖注入”。
其实官方二十多个工具类型,核心思想只有四五种:约束、映射、条件、分配、infer。把每个工具类型展开成泛型 + 约束 + 映射/条件 + infer,你会发现全是排列组合。这也是我建议读者不要死记工具类型名字,而是自己实现一遍的原因。
5. 真实项目中的泛型封装:API 请求、表单状态与容器
5.1 一个统一请求方法的类型设计
在项目里最常见的泛型应用就是封装请求层。假设后端统一返回 code、message、data 结构,我用原生 fetch 做一次封装:
interface ApiResponse<T = unknown> { code: number; message: string; data: T; } async function http<T = unknown>( url: string, init?: RequestInit ): Promise<T> { const response = await fetch(url, init); const body: ApiResponse<T> = await response.json(); if (body.code !== 0) { throw new Error(body.message); } return body.data; } // 使用时,类型的精确度全由调用方指定 const user = await http<User>('/api/user', { method: 'POST', body: JSON.stringify({ id: 1 }), }); // user 在这里的类型是 User,而不是 ApiResponse<User>注意这里的层次:http<T>的函数声明告诉外部返回 Promise ,而内部把响应体解析成 ApiResponse ,解包后再把 data 透传出去。这层封装把“统一错误处理”和“类型透传”结合得很自然。泛型默认值T = unknown让不传类型时的行为仍然是安全的,而不是退化成 any。真实请求库的封装逻辑也类似,只是把 fetch 换成具体请求方法而已。
这个设计里值得思考的是:为什么返回类型不是 Promise<ApiResponse > 而要写 Promise ?因为业务方关心的是 data,错误已经被统一抛出。类型设计本质上是在表达“这个函数对外承诺什么”,承诺越精确,使用方越省心。
5.2 表单校验的类型安全:用 keyof 约束字段
另一个实用场景是表单校验。写过一个把“校验规则表”和“表单值”绑定在一起的封装,要求调用方不能写出不存在的字段:
type FormValues = { name: string; age: number }; const validators: Record<keyof FormValues, (value: any) => boolean> = { name: (value) => typeof value === 'string' && value.length > 0, age: (value) => typeof value === 'number' && value >= 0, }; function validate<T extends object>( values: T, rules: Record<keyof T, (value: T[keyof T]) => boolean> ): boolean { return (Object.keys(rules) as Array<keyof T>).every((key) => rules[key](values[key]) ); }关键点在rules: Record<keyof T, (value: T[keyof T]) => boolean>。这一行的意思是:规则表必须包含 T 的所有键,且每个规则函数的入参类型就是该字段的值类型。你少写一个字段,或者把 age 的校验函数入参写成 string,编译器会直接报错。这种“规则表和数据结构天然一致”的约束,正是泛型在业务代码里最有存在感的地方。
需要说明的是,value: T[keyof T]在这里取的其实是所有字段类型的联合,所以校验函数内部往往还需要自行收窄。如果想每个字段各自精确到类型,可以继续用更复杂的映射,但业务上很少需要一步到位。
5.3 用泛型写一个最小状态容器
最后看一个把泛型类和泛型方法混在一起的例子,一个极简的发布订阅状态容器:
type Listener<T> = (state: T) => void; class Store<T extends object> { private state: T; private listeners: Listener<T>[] = []; constructor(initial: T) { this.state = initial; } getState(): T { return this.state; } subscribe(listener: Listener<T>): () => void { this.listeners.push(listener); return () => { const index = this.listeners.indexOf(listener); if (index >= 0) this.listeners.splice(index, 1); }; } setState(partial: Partial<T>): void { this.state = { ...this.state, ...partial }; this.listeners.forEach((listener) => listener(this.state)); } } const store = new Store({ count: 0, label: 'hello' }); store.setState({ count: 1 }); // store.setState({ unknownField: 1 }); // 报错,unknownField 不存在store 的类型参数在实例化时确定,setState 接收 Partial ,所以支持局部更新但字段名和类型都受控。这种模式在很多状态管理库的设计里都能看到,自己写一遍能明显提升对类泛型的理解。封装是否足够灵活是一回事,更重要的是你能快速读懂别人写的这类容器代码。
6. 泛型调试中的几个坑,以及一条可复现的排查链路
6.1 约束之后类型“没有变化”:extends 不等于 in
我在写自定义 Pick 时踩过一个典型坑。当时期望一个泛型在 extends 约束之后自动收窄,但 TypeScript 没有按想的走。原因在于 extends 只是约束了入参必须满足的条件,函数体内部的类型仍然基于“未知的 T”,它只知道 T 在约束范围内,不会替你推断出某一个具体分支。这时候需要显式做类型收窄,比如判断 typeof 或者使用类型断言。
这种问题的排查方式很折腾:你点进类型定义,发现类型没错;你测试一个具体类型,发现也没错;但一放到泛型函数里,类型就是没按分支走。直到意识到“T 只有一个约束条件,而不是一个具体值”才彻底解开。泛型函数内部一定不要假设 T 是某个具体类型,编译器给你的自由度是用“你必须手动收窄”换来的。
6.2 条件类型的分发把联合类型“拆碎了”
另外一个高频坑是条件类型分发。写过一段类似T extends string ? A : B的代码,期望 T 是string | number时整体走一个分支,结果却发现结果变成了A | B——因为条件类型面对联合类型会自动分发。想关闭分发,用方括号包住类型参数即可:
type NoDistribute<T> = [T] extends [string] ? 'string-only' : 'mixed'; type Test = NoDistribute<string | number>; // 'mixed'顺带一提,never 在条件类型里的表现也很反直觉。never extends string这个判断恒成立,条件类型传入 never 时会返回 true 分支;如果不想这样,常用的手法是先做一次IsNever<T>判断再进入真正逻辑。
6.3 infer 的位置与必须条件
infer 只能放在条件类型里,且必须在 true 分支中通过 infer 声明一个新类型变量,这导致很多人一上来写type X<T> = infer U这类代码,编译器直接报错。解决办法不是死记“infer 只能在条件类型里”,而是回到底层逻辑:infer 是在“匹配某个结构”的过程中做捕获,没有匹配过程就没有捕获目标。所以至少要有一个能被匹配的结构(函数类型、数组类型、Promise 类型),并且把它放在条件类型左侧。
另外 infer 捕获的类型是“声明即用”的,不需要也不能在别处再声明一次。如果你在多个位置写了同一个变量名,比如T extends [infer U, infer U],它表达的是“两个位置都捕获,但类型是否严格相等并不保证”,这是另一个常见的迷惑点。
6.4 泛型默认值与参数顺序
泛型默认值看起来简单,但有一个硬性规则:有默认值的类型参数必须出现在没有默认值的后面。这意味着type Foo<T = unknown, U>是非法的,顺序调换才合法。设计公共类型时最好把必选参数放前面,可选参数放后面,和函数参数的规则一致。
还有一个容易被忽略的细节:泛型默认值会影响推断。如果调用方既不显式传类型参数,编译器又无法从参数推断,此时它不会尝试拿默认值参与计算,而是直接把这个泛型参数视为默认类型,两种情况推导出的结果可能不同。遇到奇怪的推断行为时,先把它列为怀疑对象。
6.5 什么时候不用泛型:过度抽象的坏味道
最后聊反向问题。泛型虽然强大,但并非万金油。我见过有人把两个具体的类型合并成一个T extends object的“万能类型”,函数内部再用一堆断言把类型救回来,代码读起来跟解密一样。这其实是把编译期能检查的类型问题推迟到了运行期,违背了泛型的初衷。
我的经验是,当你发现一个泛型参数在整个类型推导链路里只出现一次,而且这个出现并不影响其他类型的推导时,很可能就不需要泛型。泛型的第一价值是“建立不同类型之间的约束关系”,如果关系不存在,它只是让代码更复杂。另一个简单原则是:先写具体类型的版本,等重复三次之后再考虑抽象成泛型。抽象一旦上移,维护成本的增加是立刻的。
写到这里,我回想自己从排斥泛型到离不开它,中间最大的转折其实是换了一个视角:不再把泛型当成一门需要死记硬背的语法,而是看成类型系统里的一层“参数化抽象”。每当你发现一个函数、接口、类型在不同地方反复出现、只差类型不同,泛型大概率就是正确的扶手;而当你发现类型参数之间毫无约束关系时,就要警惕它是不是在制造额外复杂度。理解泛型的收益不只是少写几个 any,而是可以让编译器真正帮我们兜住业务结构里的约束,把一批运行时才暴露的问题提前到写码阶段。最后分享一个小技巧:遇到复杂泛型类型,永远先写一个具体类型的版本再抽象,并且随时用鼠标悬停去查看展开后的实际类型,这比任何文档都靠谱。我到现在写公共 API 封装时,仍然会先用一个 User 或 Order 之类的实体跑通,再把类型参数泛化,这套流程帮我少踩了很多推断细节的坑。