写 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 firstT(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 pairA, B(a: A, b: B): [A, B] { return [a, b]; } const p1 pair(1, one); // 类型自动推断为 [number, string] const p2 pairstring, number(no, 2); // 也可以显式指定显式指定的场景多半出现在类型推断“猜不中”的时候。举个例子如果一个函数希望参数是一个具体的字面量类型但你又不想用as const以外的方式限制它这时候显式类型参数可以帮你。还有一些场景涉及泛型默认值后面会谈到。有一个细节必须提醒在.tsx文件里function firstT这种函数声明没有解析问题但箭头函数写成T(arr: T[]) ...就会出问题因为 JSX 解析器会把T当成一个 JSX 标签。标准写法是在尖括号后面加一个逗号T,(arr: T[]) ...。很多团队直接在项目里统一启用“箭头函数 泛型都加逗号”的规范避免心智负担这是新手最容易忽视的报错来源。2.2 接口和类型别名中的泛型让类型结构可复用接口里也可以用泛型典型如把某个字段的类型作为参数interface ApiResponseT { code: number; message: string; data: T; } type User { id: number; name: string }; const res: ApiResponseUser { code: 0, message: ok, data: { id: 1, name: foo }, };这个 ApiResponse 在真实项目里几乎每个团队都会写一份。要理解的是接口在使用时才传入 T就像函数在使用时才传入参数。它描述的是一个“骨架”具体字段类型可以由使用方决定。这样一套响应结构就能适配任意 data 形态而不再需要为每个实体类型单独写一个 Response 接口。类型别名里同样支持泛型写法和接口几乎一致。区别主要在于type 可以表示联合类型、条件类型这类“值不是普通对象结构”的类型interface 描述对象形状更自然。两者绝大多数场景选一个用就行不必纠结。类的泛型也类似在类的名字后加T然后这个参数可以在成员类型、方法参数和返回值中使用。例如一个比较简单的状态容器class StateHolderT { private state: T; constructor(initial: T) { this.state initial; } get(): T { return this.state; } set(next: T): void { this.state next; } }这个 StateHolder 实例化的时候一旦确定 Tget 和 set 的类型就都确定了。类泛型在使用上最大的坑是如果你在类的方法里希望泛型方法能“临时引入”另一个独立的类型参数那么这个类型参数属于方法而不是类两者不能混用。这是早期我想不通的一个点后面踩坑部分会详细展开。2.3 泛型约束 extends 与 keyof给类型参数加范围类型参数如果完全不设限制那就什么都传得进来。真实业务里经常需要限定“传入的类型必须满足某个条件”比如必须是一个对象、必须包含某个字段、必须是 number 或 string 之一。这就是extends约束function getLengthT 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 getPropT 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 IsArrayT T extends any[] ? true : false; type A IsArraystring[]; // true type B IsArraynumber; // false你可以把它读成一段类型层面的 if-else。真正的复杂度隐藏在“分配率”里当 T 是一个联合类型时条件类型会对联合类型的每个成员分别执行判断最后把结果组成新的联合类型。type DiffT, U T extends U ? never : T; // Diffa | 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 分支里作用是从一个类型结构中提取出一个子类型。最经典的例子是自己实现 ReturnTypetype MyReturnTypeT extends (...args: any[]) any T extends (...args: any[]) infer R ? R : never; function fetchUser() { return { id: 1, name: foo }; } type User MyReturnTypetypeof 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 | limittypeof 在这里不是运行时运算符它在类型位置上出现时是 TS 的“值转类型”语法。真实项目里经常会遇到一个常量配置对象定义好了希望另一个类型结构自动跟随它的键这就是最省心的做法。配合同样来自对象的值类型typeof initialCounters[keyof typeof initialCounters]可以让类型跟随数据变化减少大量手工维护重复类型的成本。4. 把内置工具类型拆开看每个都是教科书级的泛型案例4.1 映射类型的基础Partial、Required、ReadonlyTypeScript 自带了一套工具类型官方文档对它们的解释往往是“把属性变成可选/只读”。但太多人只会用说不出原理遇到需求变化就懵。其实这几个工具类型就是“映射类型”三板斧的组合keyof 取键、in 遍历、属性修饰符加减。type MyPartialT { [K in keyof T]?: T[K] }; type MyRequiredT { [K in keyof T]-?: T[K] }; type MyReadonlyT { readonly [K in keyof T]: T[K] };[K in keyof T]可以被理解成 for-in 循环遍历 T 的每一个键然后逐个声明新类型的属性。?和readonly是属性修饰符-?表示移除可选标记-readonly表示移除只读标记。理解这一层之后你遇到“把所有属性变成可选但保留个别必选”这类需求时就能基于内置类型组合去写而不是去网上抄一段不明所以的代码。4.2 Pick 与 Record对象结构的选择与构造再看两个方向相反的工具类型。Pick 是从一个对象类型里挑出若干键Record 是从一个键集合构造一个对象类型type MyPickT, K extends keyof T { [P in K]: T[P] }; type MyRecordK extends keyof any, V { [P in K]: V };MyPick 很容易理解遍历 KK 被约束为 T 的键把对应的属性原样保留。MyRecord 则是把“键集合”映射成“统一值的对象”比如定义一个枚举到文案的映射表type Status pending | done | failed; const statusText: RecordStatus, string { pending: 等待中, done: 已完成, failed: 失败, };注意这里的keyof any其实就是string | number | symbol它是 TypeScript 内置的“所有可作为对象键的类型”集合。Record 的 K 被限制成一个标准的键类型可以防止传入了完全不能做键的类型。4.3 条件工具类型Exclude、Extract、NonNullable条件类型工具在业务型工具类型里也很常见。Exclude 前面讲过就是“从联合类型里剔除一部分”。Extract 反过来保留两个联合类型的交集type MyExcludeT, U T extends U ? never : T; type MyExtractT, U T extends U ? T : never; type MyNonNullableT T extends null | undefined ? never : T;联合类型 条件类型的分配律会反复出现所以建议把它当作默认心态看到T extends U ? X : Y这种写法第一反应是“T 如果是联合类型会被拆开处理”。这三个工具类型在枚举过滤、空值剔除场景里非常实用尤其 NonNullable 配合接口字段做清洗能省掉一堆重复的类型守卫。4.4 ReturnType、Parameters函数类型上的元编程最后看一组和函数相关的工具类型。它们内部都用到了 infertype MyReturnTypeT extends (...args: any[]) any T extends (...args: any[]) infer R ? R : any; type MyParametersT 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 ApiResponseT unknown { code: number; message: string; data: T; } async function httpT unknown( url: string, init?: RequestInit ): PromiseT { const response await fetch(url, init); const body: ApiResponseT await response.json(); if (body.code ! 0) { throw new Error(body.message); } return body.data; } // 使用时类型的精确度全由调用方指定 const user await httpUser(/api/user, { method: POST, body: JSON.stringify({ id: 1 }), }); // user 在这里的类型是 User而不是 ApiResponseUser注意这里的层次httpT的函数声明告诉外部返回 Promise 而内部把响应体解析成 ApiResponse 解包后再把 data 透传出去。这层封装把“统一错误处理”和“类型透传”结合得很自然。泛型默认值T unknown让不传类型时的行为仍然是安全的而不是退化成 any。真实请求库的封装逻辑也类似只是把 fetch 换成具体请求方法而已。这个设计里值得思考的是为什么返回类型不是 PromiseApiResponse 而要写 Promise 因为业务方关心的是 data错误已经被统一抛出。类型设计本质上是在表达“这个函数对外承诺什么”承诺越精确使用方越省心。5.2 表单校验的类型安全用 keyof 约束字段另一个实用场景是表单校验。写过一个把“校验规则表”和“表单值”绑定在一起的封装要求调用方不能写出不存在的字段type FormValues { name: string; age: number }; const validators: Recordkeyof FormValues, (value: any) boolean { name: (value) typeof value string value.length 0, age: (value) typeof value number value 0, }; function validateT extends object( values: T, rules: Recordkeyof T, (value: T[keyof T]) boolean ): boolean { return (Object.keys(rules) as Arraykeyof T).every((key) rules[key](values[key]) ); }关键点在rules: Recordkeyof T, (value: T[keyof T]) boolean。这一行的意思是规则表必须包含 T 的所有键且每个规则函数的入参类型就是该字段的值类型。你少写一个字段或者把 age 的校验函数入参写成 string编译器会直接报错。这种“规则表和数据结构天然一致”的约束正是泛型在业务代码里最有存在感的地方。需要说明的是value: T[keyof T]在这里取的其实是所有字段类型的联合所以校验函数内部往往还需要自行收窄。如果想每个字段各自精确到类型可以继续用更复杂的映射但业务上很少需要一步到位。5.3 用泛型写一个最小状态容器最后看一个把泛型类和泛型方法混在一起的例子一个极简的发布订阅状态容器type ListenerT (state: T) void; class StoreT extends object { private state: T; private listeners: ListenerT[] []; constructor(initial: T) { this.state initial; } getState(): T { return this.state; } subscribe(listener: ListenerT): () void { this.listeners.push(listener); return () { const index this.listeners.indexOf(listener); if (index 0) this.listeners.splice(index, 1); }; } setState(partial: PartialT): 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 NoDistributeT [T] extends [string] ? string-only : mixed; type Test NoDistributestring | number; // mixed顺带一提never 在条件类型里的表现也很反直觉。never extends string这个判断恒成立条件类型传入 never 时会返回 true 分支如果不想这样常用的手法是先做一次IsNeverT判断再进入真正逻辑。6.3 infer 的位置与必须条件infer 只能放在条件类型里且必须在 true 分支中通过 infer 声明一个新类型变量这导致很多人一上来写type XT infer U这类代码编译器直接报错。解决办法不是死记“infer 只能在条件类型里”而是回到底层逻辑infer 是在“匹配某个结构”的过程中做捕获没有匹配过程就没有捕获目标。所以至少要有一个能被匹配的结构函数类型、数组类型、Promise 类型并且把它放在条件类型左侧。另外 infer 捕获的类型是“声明即用”的不需要也不能在别处再声明一次。如果你在多个位置写了同一个变量名比如T extends [infer U, infer U]它表达的是“两个位置都捕获但类型是否严格相等并不保证”这是另一个常见的迷惑点。6.4 泛型默认值与参数顺序泛型默认值看起来简单但有一个硬性规则有默认值的类型参数必须出现在没有默认值的后面。这意味着type FooT unknown, U是非法的顺序调换才合法。设计公共类型时最好把必选参数放前面可选参数放后面和函数参数的规则一致。还有一个容易被忽略的细节泛型默认值会影响推断。如果调用方既不显式传类型参数编译器又无法从参数推断此时它不会尝试拿默认值参与计算而是直接把这个泛型参数视为默认类型两种情况推导出的结果可能不同。遇到奇怪的推断行为时先把它列为怀疑对象。6.5 什么时候不用泛型过度抽象的坏味道最后聊反向问题。泛型虽然强大但并非万金油。我见过有人把两个具体的类型合并成一个T extends object的“万能类型”函数内部再用一堆断言把类型救回来代码读起来跟解密一样。这其实是把编译期能检查的类型问题推迟到了运行期违背了泛型的初衷。我的经验是当你发现一个泛型参数在整个类型推导链路里只出现一次而且这个出现并不影响其他类型的推导时很可能就不需要泛型。泛型的第一价值是“建立不同类型之间的约束关系”如果关系不存在它只是让代码更复杂。另一个简单原则是先写具体类型的版本等重复三次之后再考虑抽象成泛型。抽象一旦上移维护成本的增加是立刻的。写到这里我回想自己从排斥泛型到离不开它中间最大的转折其实是换了一个视角不再把泛型当成一门需要死记硬背的语法而是看成类型系统里的一层“参数化抽象”。每当你发现一个函数、接口、类型在不同地方反复出现、只差类型不同泛型大概率就是正确的扶手而当你发现类型参数之间毫无约束关系时就要警惕它是不是在制造额外复杂度。理解泛型的收益不只是少写几个 any而是可以让编译器真正帮我们兜住业务结构里的约束把一批运行时才暴露的问题提前到写码阶段。最后分享一个小技巧遇到复杂泛型类型永远先写一个具体类型的版本再抽象并且随时用鼠标悬停去查看展开后的实际类型这比任何文档都靠谱。我到现在写公共 API 封装时仍然会先用一个 User 或 Order 之类的实体跑通再把类型参数泛化这套流程帮我少踩了很多推断细节的坑。