尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TypeScript类型体操:泛型、条件类型与infer实战解析

TypeScript类型体操:泛型、条件类型与infer实战解析 如果你和我一样花了两三个小时刷完所谓的“快速上手 TypeScript”教程当时觉得泛型、接口、类型别名都整明白了但一回到真实项目看到同事封装的DeepPartialT或者ReturnTypetypeof fn还是会愣一下——那恭喜你你已经摸到了 TypeScript 进阶的门口。我自己的转机出现在一次代码评审上。同事写了一个很小的类型工具能把某个接口里的所有字段变成可选我当时的反应是“这玩意儿是不是用了什么黑魔法”。逐行看完才发现核心语法说白了就是泛型加条件类型再加一个关键操作符infer。这三样东西就是大家常说的“类型体操”的地基。这篇文章想把这套东西从头讲透泛型的本质是什么条件类型为什么可以理解成类型世界里的 if/elseinfer到底在干什么以及怎么用它们自己动手实现那些高频工具类型。适合已经能用 TS 写业务、但碰到高级类型还是发怵的同学也适合准备面试时被“类型体操”问住的同学。1. 类型体操的门槛不在语法在思维方式的转换1.1 从一次让我“破防”的代码评审说起当时同事写的代码大概是这样type DeepPartialT { [K in keyof T]?: T[K] extends object ? DeepPartialT[K] : T[K]; };我盯着extends object和递归调用看了半天第一反应是“这不就是 JS 里的三元表达式吗”第二反应是“它为什么能自己调用自己”。后来才意识到我一直在用“写业务类型”的思维去理解类型系统所以看到这种递归、条件、映射混在一起的东西就懵。其实这类代码在真实项目里出现频率远比想象中高。表单校验、API 响应包裹、状态管理仓库、甚至路由配置都会用到“把一个接口结构转换成另一个接口结构”的需求。这跟普通业务代码的区别在于我们不是在操作值而是在操作类型。1.2 类型体操真正解决的问题把“程序逻辑”搬进类型系统很多人觉得类型体操是炫技但我的看法不一样。运行时 JavaScript 用函数处理值类型体操本质上是在类型系统中做同样的计算泛型就是函数参数条件分支就是 if/else映射类型就是循环遍历infer就是结构解构。你可以把类型系统想象成一台额外的虚拟机它在编译期把你要表达的约束全部算完。算得越快、越精确开发期的错误拦截就越早。比如Partial把一个对象类型变成全部可选这本质上是“遍历所有属性并加上 ? 修饰符”的循环逻辑ReturnType则是“读函数类型返回值类型”的动态计算。这些操作在运行时不存在但编译期一直在跑。1.3 为什么说泛型和条件类型是地基翻开 TypeScript 自带的所有工具类型源码你会发现它们几乎全部由三块积木搭成泛型参数、条件类型、infer 提取。Partial、Pick是纯泛型加映射Exclude、Extract是标准条件类型ReturnType、Parameters是 infer 配合条件类型。你把这三块吃透内置工具类型就等于开了源码视角连看 NestJS、Prisma、React 类型定义都不再心虚。所以这篇文章不搞花活先把这三块地基用最直白的方式讲清楚。2. 泛型类型层面的参数化先理解这四件事2.1 泛型的本质是类型层面的“函数参数”我见过太多人把泛型背成“一种可以在定义时不指定具体类型、使用时才确定的类型”。这个说法没错但它没讲到点子上。泛型更像工厂里的模具你现在不知道要生产什么尺寸的零件但你知道每个零件都需要“被加工、被检验、被包装”这一套流程。等订单来了把具体尺寸填进去模具开始干活。所以在 TS 里泛型就是“类型的函数参数”function identityT(arg: T): T { return arg; } const a identitystring(hello); const b identity(123); // 类型推导出 T number这里T不是 any它是一个精确的占位符。调用identity(hello)时T被具体化成string返回类型也同时变成string。这就是“保留关系”function identityWrong(arg: any): any { return arg; }换成 any 之后你确实也能拿到“值”但参数和返回值之间的关联断了。TS 不知道你传入的是什么、返回的是什么后续所有类型推导都会退化成“放弃治疗”。2.2 泛型与 any 的根本区别关系比结果更重要很多人刚学泛型时最大的困惑是“我用 any 也能实现同样的效果为什么非要泛型”区别在于泛型让类型之间有约束关系any 只是把所有关系剪断。举个例子你想要一个函数接收一个对象和一个 key返回对象里对应 key 的值function getValueT, K extends keyof T(obj: T, key: K): T[K] { return obj[key]; } const user { name: 张三, age: 30 }; const name getValue(user, name); // string const age getValue(user, age); // number如果写成(obj: any, key: string): any你也能跑但调用方完全失去了类型保护——没人保证 key 一定存在于 obj 上也没人保证返回值是什么类型。而泛型版本里K extends keyof T已经限定了第二个参数必须是第一个参数的合法键名返回值也精确对应到这个键的类型。一个“在编译期就拦住错误”的 API和一个“运行到那行才炸”的 API工程价值完全不在一个数量级。2.3 泛型约束extends 不是摆设是门槛K extends keyof T里的extends是泛型约束的关键语法。它的意思是K 必须是 keyof T 的子类型或者说 K 能被赋值给 keyof T。约束的意义不只是“限制”更重要的价值是约束让类型参数拥有了可用的操作空间。比如你想写一个函数接收任意带length属性的参数interface Lengthwise { length: number; } function logLengthT extends Lengthwise(arg: T): T { console.log(arg.length); return arg; } logLength(hello); // 字符串有 length合法 logLength([1, 2, 3]); // 数组有 length合法 logLength(123); // 报错number 没有 length为什么需要extends Lengthwise因为如果 T 是任意类型arg.length在类型层面是不存在的。约束之后类型参数 T 不再是一个无限大的集合而是一个被收窄到“包含 length: number”的集合你才能放心访问这个属性。2.4 类型推断、多参数与默认值的实战细节泛型在多数情况下不需要显式传参TS 会自动从函数参数推断。但有几个细节容易踩坑多泛型参数的顺序问题function swapT, U(pair: [T, U]): [U, T] { return [pair[1], pair[0]]; } const swapped swap([hello, 42]); // [number, string]这里 TS 能同时推断出T string、U number但如果某个参数没法从入参推断就得在调用时手动指定比如swapstring, number(...)。泛型默认值在类型工具里非常常见它能让调用方少写一个参数type CreateArrayT, U T T extends unknown ? U[] : never;在实际工具类型源码里默认值常用来“绑定”“简化”类型参数。比如ReturnTypeT extends (...args: any) any any虽然默认值不优雅但能防止错误使用时直接报出读不懂的长错误。提示写泛型时我习惯先问自己三个问题——这个类型参数未来可能是什么我需要约束它的形状吗调用方需要显式传参还是希望自动推导想清楚再动手比直接堆T强得多。3. 条件类型extends 背后的“可赋值性”逻辑3.1 条件类型的语法就是类型版的 if/else条件类型长这样type IsStringT T extends string ? true : false; type A IsStringstring; // true type B IsStringnumber; // falseT extends string在这里不是“T 继承 string”而是“T 能否赋值给 string”。如果成立走?分支不成立走:分支。这与泛型约束里的extends其实是同一个判断机制只是位置不同——一个出现在类型定义处一个出现在类型计算表达式里。它可以嵌套也可以多层分支type TypeNameT T extends string ? string : T extends number ? number : T extends boolean ? boolean : other;这种三角嵌套看起来很占地方但它是 TypeScript 里唯一能按条件“选择类型”的原生语法。你要处理复杂分支只能靠它。3.2 判断的本质是“可赋值性”不是“相等”这是条件类型最反直觉的一点。extends判断的并不是两个类型“长得一样”而是“左边能否赋值给右边”。比如下面这段很多人会猜false但结果是truetype Result hello extends string ? true : false; // true原因很简单字面量类型hello可以赋值给string。反过来呢type Result2 string extends hello ? true : false; // falsestring不能赋值给hello因为字符串类型比单个字面量宽得多不能保证一定是hello。理解这一点后很多“奇怪的判断结果”就说得通了。比如any的特殊性any extends string ? true : false结果是true | false这个联合类型。因为any表示“可能是任何类型”TS 干脆认为两个分支都可能发生。遇到这种结果别慌它不是在跟你开玩笑而是在如实表达“any 无法确定”。3.3 条件类型配合泛型才真正发挥威力单独写条件类型用处不大但它一旦跟泛型配合就变成了一个“类型函数”type TrueTypeT T extends string ? 是字符串 : 不是字符串; type A TrueTypestring; // 是字符串 type B TrueTypenumber; // 不是字符串这里TrueType等价于一个接收类型参数T的函数函数体就是T extends string ? ... : ...。每传入一个具体类型它就算出一个结果。这就是类型体操的编程模型泛型定义输入条件类型定义分支逻辑映射类型或者递归来定义循环。后面的所有高级工具类型基本都是在这个模型上叠加出来的。4. 分布式条件类型与 never 的特殊行为最容易翻车的地方4.1 联合类型会自动“拆开”逐一分发如果你把联合类型传入一个裸泛型参数的条件类型结果会和预期差异很大type ToArrayT T extends unknown ? T[] : never; type Result ToArraystring | number; // 结果是 string[] | number[]而不是 (string | number)[]这就是分布式条件类型distributive conditional type。规则很简单当泛型参数 T 是“裸类型参数”没有被[]包裹也没有被其他类型夹住时如果传入联合类型TS 会把联合类型拆成每个成员单独计算最后把所有结果再合并成联合类型。上面的例子实际上是先算ToArraystring得到string[]再算ToArraynumber得到number[]最后合并成string[] | number[]。这个机制非常有用Exclude、Extract、NonNullable全部建立在这上面type MyExcludeT, U T extends U ? never : T; // Excludestring | number | boolean, boolean // 相当于 // string extends boolean ? never : string - string // number extends boolean ? never : number - number // boolean extends boolean ? never : boolean - never // 合并string | number配合内置类型实现type MyNonNullableT T extends null | undefined ? never : T;4.2 手动关闭分配律的两种姿势有些场景你不想让联合类型被拆开而是希望把它整体当作一个类型来处理。这时候把泛型参数“包一层”即可type ToArrayNonDistT [T] extends [unknown] ? T[] : never; type Result ToArrayNonDiststring | number; // (string | number)[]把 T 包在元组[T]里T 就不再是“裸类型参数”分配律被关闭。这种写法在实现IsNever、IsAny这类判断工具时几乎是必备的type IsNeverT [T] extends [never] ? true : false; type A IsNevernever; // true type B IsNeverstring; // false注意如果你写成type IsNeverT T extends never ? true : false;再传入never结果是never而不是true。原因是never是空联合类型分布式条件类型没法对它分发直接返回never。这个坑我在面试题里见过无数次一定要记住。4.3 条件类型里的 any 与 unknown跟never类似any和unknown在条件类型里也有各自的“脾气”输入类型T extends string ? true : false解释anytrue | falseany 可以代表任何类型TS 两个分支都保留unknownfalseunknown 不能赋值给 stringnevernever空联合类型分发后直接返回 neverhellotrue字面量可以赋值给 stringstring[]false数组不能赋值给 string这个表格是我自己总结的每次写条件类型前都会过一遍能省掉大量“为什么结果不是我想要的”调试时间。5. infer从条件类型里长出的一把“类型提取器”5.1 infer 的基本语法一个只能出现在 true 分支里的声明infer是 TypeScript 里最容易被误解的关键字。它不定义新类型而是在条件类型匹配的过程中“顺手”声明一个类型变量并捕获结果。语法固定长这样type SomeTypeT T extends (某结构) ? (使用 infer 捕获变量) : (兜底);最常见的就是提取数组元素类型type ArrayItemT T extends Arrayinfer U ? U : never; type A ArrayItemstring[]; // string type B ArrayItemnumber[]; // number这里Arrayinfer U相当于在写“如果 T 能匹配一个数组类型就声明变量 U 来存放数组元素的类型”。TS 会自动推断U然后在 true 分支里你就能直接用U。infer只能出现在条件类型extends子句的 true 侧即?之前的结构里不能在 false 分支或外层随意使用。5.2 高频提取ReturnType 与 ParametersReturnType是内置工具类型里最经典的一个完整实现是这样的type MyReturnTypeT extends (...args: any[]) any T extends (...args: any[]) infer R ? R : never; type Fn (name: string, age: number) boolean; type Result MyReturnTypeFn; // boolean理解这条类型关键是看(...args: any[]) infer R这段它不是在声明“一个返回类型为 R 的新函数”而是在描述一种“形状匹配”。TS 会把传入的Fn和这个模式比对发现Fn是一个函数类型于是把它的返回值类型捕获到 R。Parameters也是同理只不过捕获的是参数列表type MyParametersT extends (...args: any[]) any T extends (...args: infer P) any ? P : never; type Fn (name: string, age: number) boolean; type Result MyParametersFn; // [name: string, age: number]5.3 继续深入提取字符串前缀、Promise 内部值有了 infer你能做的事情就不仅仅是函数类型了。任何结构化的类型都可以通过“模式匹配”抽取内部信息。比如提取字符串类型中某个分隔符前面的部分type GetPrefixT extends string T extends ${infer Prefix}/${string} ? Prefix : T; type A GetPrefixuser/profile; // user type B GetPrefixlogin; // login这是 TypeScript 4.1 引入模板字符串类型之后最常见的玩法。${infer Prefix}/${string}描述了一个“以任意内容开头紧跟 /后面是任意字符串”的结构Prefix就是被捕获的头部。再比如提取 Promise 包装后的内部类型这也是处理异步请求时的高频需求type PromiseValueT T extends Promiseinfer V ? V : T; type A PromiseValuePromisestring; // string type B PromiseValuestring; // string5.4 递归 infer层层解包的通用模式如果 Promise 里套 Promise呢比如PromisePromisestring上面的PromiseValue只解一层结果还是Promisestring。这时候就要用递归type DeepPromiseValueT T extends Promiseinfer V ? DeepPromiseValueV : T; type A DeepPromiseValuePromisePromisestring; // string type B DeepPromiseValuePromisenumber; // number这个写法背后的思路跟递归函数一模一样先把最外层的 Promise 结构解开拿到内部类型 V如果 V 还是 Promise就继续递归直到不再是 Promise返回最底层的类型。理解了这个模式之后看DeepReadonly、DeepPartial的实现都会觉得顺理成章。6. 动手实现高频工具类型从内置到深递归6.1 先实现两个最基础的Pick 与 Omit理论讲了这么多不落地就没意义。先从最基础的开始Pick是从对象类型里挑出若干属性type MyPickT, K extends keyof T { [P in K]: T[P]; }; interface User { id: number; name: string; age: number; } type UserBasic MyPickUser, id | name; // { id: number; name: string }它的核心是映射类型[P in K]等价于遍历联合类型 K 里的每一个键然后取值T[P]。Omit是反过来排除指定属性用除法思路可以直接组合Exclude和Picktype MyOmitT, K extends keyof T MyPickT, Excludekeyof T, K;这里先用Excludekeyof T, K算出“剩余属性”再交给 Pick 挑出来。你会发现一旦理解了Exclude的分发机制Omit 就是白送的。6.2 递归处理嵌套结构实现 DeepReadonly这是最接近 JS 里“深拷贝”的思维训练。我们要把对象类型的所有层级的属性都加上readonlytype DeepReadonlyT { readonly [K in keyof T]: T[K] extends Recordstring, unknown | readonly unknown[] ? DeepReadonlyT[K] : T[K]; }; interface Config { server: { host: string; port: number; }; list: string[]; } type ReadonlyConfig DeepReadonlyConfig; // 内部 server 和 list 的层级也被加上 readonly这里有三个细节第一内部判断用了Recordstring, unknown | readonly unknown[]是为了排除函数和基本类型——函数虽然也是 object但递归进去没有任何意义反而会破坏类型。第二递归条件分支里调用自身DeepReadonlyT[K]相当于对每个值类型再跑一遍完整流程。第三数组类型readonly unknown[]的匹配能覆盖string[]、number[]等所有数组类型。如果你写的条件太窄类型工具的覆盖范围就会受限。6.3 利用模板字符串类型做“路径”级类型操作有了 infer 和模板字符串你甚至可以把类型计算做到“字符串级别”。比如把 API 的路径字符串变成对象属性路径的联合类型type PathToObjectT extends string T extends ${infer Left}.${infer Right} ? { [K in Left]: PathToObjectRight } : { [K in T]: string }; type Path PathToObjectuser.profile.address; // { // user: { // profile: { // address: string; // }; // }; // }这个工具类型在配置化系统里很实用——给定一个路径字符串自动生成嵌套对象类型让配置对象的格式由路径直接推导出来。核心依然是“模式匹配 递归”并没有引入新概念。6.4 用类型测试把工具锁死工具类型写完怎么保证没写错TypeScript 没有运行时的测试环境但可以写“类型级测试”type ExpectT extends true T; type EqualX, Y (T() T extends X ? 1 : 2) extends (T() T extends Y ? 1 : 2) ? true : false; type TestPick ExpectEqualMyPickUser, id, { id: number }; type TestExclude ExpectEqualMyExcludea | b, a, b;Equal这个工具是社区里常用的“严格类型相等”判断它利用的是函数泛型在赋值性下的特性比单纯X extends Y ? true : false要严格得多。把关键断言写在同一个文件里每次改动类型工具后跑一下tsc --strict类型不对会直接飘红。7. 面试与工程化视角类型体操到底该怎么学7.1 面试里的高频考法与答题思路类型体操在面试里出现频率极高尤其是中高级前端和全栈岗位。常见题型被我归成三类第一类直接让手写内置工具类型Partial、Required、Pick、Readonly、Record、Exclude、Extract、NonNullable、ReturnType、Parameters。这类题考察的是对基本语法的熟练度建议把每个内置工具的实现看熟。第二类变体或组合题比如手写DeepPartial、DeepReadonly、RequiredByKeys、PartialByKeys或者要求存到一个泛型工具里同时支持嵌套和数组。这类题考察递归与条件类型的组合能力。第三类条件类型陷阱题比如“写一个IsNever”“判断一个类型是不是any”“问string extends hello ? true : false的结果”。这类题考察对赋值性和分布式条件类型的理解最容易拉开差距。答题时我的建议是先讲思路再写代码。例如“ReturnType 的核心就是把函数类型当作一个模式去匹配用 infer R 捕获返回值我先用T extends (...args: any[]) any约束参数必须是一个函数类型再用infer R捕获返回类型最后给出兜底类型 never。”先讲思路即使代码写得不完美面试官也能看到你对机制的理解。7.2 工程里真正用到类型体操的场景类型体操不是面试专属。举几个我在真实项目里见过的落地场景NestJS / 装饰器相关装饰器拿到的元数据类型需要从目标类里反推比如“根据方法名映射参数类型”。Prisma / ORM数据库模型定义好之后自动生成WhereInput、SelectInput等类型靠的就是泛型条件类型。API 请求封装后端返回统一包裹结构{ code, data, message }前端封装requestT()时会把PromiseResponseT解析成T这里就是ReturnType infer 的实战。表单校验库根据 schema 推导表单字段类型typeof schema经过条件类型解析生成数据类型和错误类型。状态管理getState的类型经常通过索引访问T[K]和条件类型推断出来。说白了凡是有“类型转换”“接口结构变换”“从已有类型反推新类型”需求的地方类型体操都在默默干活。只是大多数时候它被封装进工具函数里业务代码看不到而已。7.3 学习路线与心态建议如果你准备系统学 TypeScript 高级类型我的建议是这样一条路线先把内置工具类型源码读一遍。打开node_modules/typescript/lib/lib.es5.d.ts不需要每个都懂先看Partial、Required、Pick、Record、Exclude、Extract、ReturnType这七个它们覆盖了大多数基础知识点。去类型挑战网站刷题。社区里很火的 type-challenges 项目从 easy 到 hard 循序渐进每题很小适合碎片时间完成。我自己的经验是前 30 题就能把泛型、条件类型、映射类型练熟之后的题目更多是“开眼界”。读一份优秀开源项目的类型定义。推荐从 VueUse、NestJS 或者小型库开始挑一个你实际用过的库看它导出的类型声明是怎么写的看到不懂的就跳到源码里对应的实现。这个过程比任何教程都涨经验。要克制别为了体操而体操。类型体操的核心价值是提升类型安全不是为了写出没人看得懂的炫技代码。我在工作里见过很多过度封装一个toString方法都要搞三层条件类型最后队友维护成本暴涨反而得不偿失。好的类型抽象应该像好的函数封装——接口简单清晰、内部逻辑复杂但是可解释。最后再分享一个个人体会学类型体操最忌讳“只看不写”因为真正让人卡住的不是读不懂而是“自己拿到需求写不出来”。哪怕只是拿一个小函数练手把它的参数类型、返回类型用泛型和条件类型写出来得到的成长也比看十篇文章多。建议你从今天开始用这篇文章里的 DeepReadonly 和 ReturnType 当模板找两个实际项目里的类型需求改造一下。真踩过几次坑之后你会发现自己再看到类型报错时心里想的不再是“这什么鬼”而是“哦这里走的是这条类型分支”。
返回列表