做TypeScript类型体操这几年,我一直想找个合适的项目把零散的类型技巧沉淀下来。后来我整理了一个叫“t3code”的TypeScript类型挑战合集,把平时在业务代码里遇得到、用得上的一些类型工具和泛型写法,按照从易到难的顺序组织成了一个小型题库。这项目运行到现在大概半年多,我把完整的踩坑过程、设计思路和实操细节都写出来,希望给也在做类型进阶的朋友一点参考。
1. 项目整体设计与思路拆解
1.1 为什么做类型挑战,而不是直接写业务组件
很多人听到“TypeScript类型挑战”的第一反应是“这不就是网上那些烧脑题吗,做它干嘛”。说实话,我一开始也这么想。但后来在真实项目里反复遇到两个痛点:一是复杂的接口返回类型要来回推导,二是一些通用型工具函数(比如路径格式化、选项联合类型、事件订阅类型)用any糊过去之后,后续的业务逻辑就完全丧失了类型保护。于是我开始系统性地把常用的类型技巧整理成题目,用“做题 + 复盘”的形式逼自己吃透每一个知识点。
“t3code”这个项目名没有什么特殊含义,当时就是想找一个简洁、容易记的代号。整个项目是基于type-challenges那套模式来的,但题目全部是我自己根据业务场景重写的,并且在每题后面额外补充了“业务中适用场景”和“常见坑点说明”。目前整体分为六个难度档位:入门、基础、进阶、高阶、地狱、业务实战,每道题都包含题目描述、初始类型定义、测试用例、参考答案、解析思路五个部分。
1.2 题库的整体组织方式
项目的目录结构大致是这样:
t3code/ ├── challenges/ │ ├── 01-warm-up/ │ │ ├── question.ts │ │ ├── test-cases.ts │ │ └── solution.ts │ ├── 02-basic/ │ ├── 03-advanced/ │ ├── 04-hard/ │ ├── 05-hell/ │ └── 06-business/ ├── utils/ │ └── type-test.ts ├── scripts/ │ └── run-tests.ts └── README.md每个题目文件夹里,question.ts放的是初始类型定义和题目要求,test-cases.ts放类型级测试用例,solution.ts放参考答案。之所以把测试用例单独拆出来,是因为我在做题过程中发现“看答案之前先跑测试”这套流程特别有效——你不需要知道最终类型长什么样,只要写出的类型能通过测试,就算你对这个知识点的理解到位了。
1.3 题目难度分级的标准
我按照自己的学习曲线,把题目分成了几个梯度。入门题主要解决“能不能写出来”的问题,比如实现一个简单的MyPick、MyReadonly,让你熟悉keyof、extends、in这些基本操作。基础题开始涉及infer、条件类型、递归,比如实现Parameters、ReturnType、Awaited、DeepReadonly。进阶题则把多个技巧组合在一起,比如实现DeepPick、TupleToUnion、PartialByKeys。高阶题开始挑战类型计算的性能边界,比如深层递归的DeepOmit、大联合类型的过滤。地狱题更多是测试你的极限,比如JSON Schema类型推导、Lint规则类型化,这些题目虽然业务中极少直接用到,但能把一些技巧练得特别扎实。
业务实战部分则完全从真实业务场景出发,包括接口响应包装类型、表单校验结果类型推导、路由参数解析类型、EventEmitter事件注册表类型等等。
2. 核心细节解析与实操要点
2.1 类型工具函数落地的三个铁律
在做这套题库的过程中,我总结出写类型工具函数的三条基本规则,这里先分享我最看重的一条:所有类型工具函数,先用团队真实业务类型来验证,再考虑通用性。举个例子,我们要写一个PickByValue类型,作用是选取对象中符合指定值类型的属性。代码写起来很直观:
type PickByValue<T, ValueType> = { [K in keyof T as T[K] extends ValueType ? K : never]: T[K] }这个类型定义本身没问题,但在实际使用中你会发现,它遇到可选属性时会和你预期的不太一样。因为T[K]的属性类型可能是string | undefined,此时T[K] extends ValueType的判断结果会比你想象中复杂。如果一开始就拿业务数据实测,这些边界情况会很快暴露出来。
第二条规则是能用映射类型解决就不用递归。这句经验我是在一个深层配置类型上踩了坑才总结出来的。当时我写了一个DeepSet类型,用于把某个深层嵌套配置对象的所有值类型统一替换:
type DeepSet<T, V> = { [K in keyof T]: T[K] extends object ? DeepSet<T[K], V> : V }看起来没问题,但当配置对象特别大、层级特别深时,TypeScript的类型检查会变得非常慢。后来我把一些不需要完全递归的场景改成“只处理特定层级”或“限制深度”,性能明显改善。类型工具的“快”不是指运行速度快,而是编辑器响应快、编译不超时,这个体验上的差距在实际团队协作中会被无限放大。
第三条规则是每个类型工具函数必须配至少5个测试用例,其中至少两个是边界值,比如空对象、嵌套空数组、联合类型。因为类型工具是给编译器看的,而不是给用户看的,一旦写错,往往不是运行时报错,而是整条类型推断链全部崩坏,排查起来特别痛苦。有测试用例兜底会安心很多。
2.2 几类高频工具类型的实现与优化
Promise结果解包这个类型工具写起来很简短,但里面有个小坑。这是最基础的版本:
type Awaited2<T> = T extends Promise<infer U> ? Awaited2<U> : T递归地解开每一层Promise,直到不再满足T extends Promise<infer U>为止。我在写递归类型时,第一次从简单的type Awaited<T> = T extends Promise<infer U> ? U : T开始,结果遇到Promise<Promise<string>>就翻车了。改成递归之后,还要注意递归深度问题。在遇到极深的嵌套时,可以引入“深度计数”来控制递归层数:
type AwaitedWithLimit<T, Depth extends number = 5> = Depth extends 0 ? T : T extends Promise<infer U> ? AwaitedWithLimit<U, Subtract<Depth, 1>> : T这里的Subtract是一个普通数字运算辅助类型,重点不在实现,而在思路:给递归加一个“安全阀”比让递归无限深入要稳妥得多。
再来看DeepReadonly,这个类型业务里用得非常多。如果你只是在线上编译器里看看效果,写个简单的递归就可以了:
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] }但如果直接把这个类型套到包含Date、RegExp、Map、Set的对象上,问题就来了——这些内置类型会被错误地展开,导致它们的方法签名全部丢失。因此在实战中要给这个工具加上一层“内置类型保护”:
type DeepReadonlySafe<T> = T extends (...args: any[]) => any ? T : T extends Date | RegExp | Map<any, any> | Set<any> ? T : { readonly [K in keyof T]: DeepReadonlySafe<T[K]> }这个调整看似简单,但在真实项目中,这个DeepReadonlySafe才是我真正会放进公共类型库的版本,而那个“简洁版”只会出现在教程里。
2.3 条件类型中的never分发问题
做题做到后面,我发现很多人的困惑不是语法不熟,而是never在条件类型中的特殊行为。题目里经常会看到这样的需求:把对象所有属性值中的never属性剔除掉。这个问题看起来简单,但如果对never的分发机制不熟,很容易写出错误的版本。错误版本长这样:
// 错误示例 type RemoveNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] }初看似乎没问题,但当T[K]本身是一个联合类型时,比如string | never,这里就有坑了。在大多数类型运算中string | never会被化简为string,但如果在条件判断之前没有把never提前过滤,一些边缘情况会出现意想不到的结果。正确的做法通常是用[T[K]] extends [never]这种元组包裹的判断方式,避免分发机制的干扰:
type IsNever<T> = [T] extends [never] ? true : false type RemoveNever<T> = { [K in keyof T as IsNever<T[K]> extends true ? never : K]: T[K] }这个IsNever工具函数几乎是我整个题库里使用频率最高的辅助类型。它不仅出现在RemoveNever里,还经常出现在联合类型的过滤、条件类型分支选择等场景。理解了never的分发机制之后,很多看起来玄学的类型问题都会变得清晰起来。
3. 实操过程与核心环节实现
3.1 实现一个完整的题目:事件订阅中心的类型推导
我挑一个比较有代表性的题目来完整展示实操过程:EventEmitter事件注册表类型。这道题在业务中很常见,而且能综合考察映射类型、条件类型和泛型约束。题目要求是这样的:定义一个EventEmitter类,支持on、emit、off方法,并且对于不同的事件名,emit传入的参数类型要能从注册表里推导出来。
首先定义事件注册表:
interface EventMap { 'user:login': { userId: string; token: string } 'user:logout': { userId: string } 'app:error': { message: string; code: number } 'app:ready': {} }然后实现EventEmitter类型:
type EventEmitter<T extends Record<string, any>> = { on<K extends keyof T>( eventName: K, handler: (payload: T[K]) => void ): void emit<K extends keyof T>(eventName: K, payload: T[K]): void off<K extends keyof T>( eventName: K, handler: (payload: T[K]) => void ): void }如果是第一次接触泛型约束K extends keyof T,可能会觉得这部分抽象。但换个角度看就很好理解:T是事件注册表,K只能是T里的键名之一,所以T[K]自然就是该事件对应的载荷类型。只要能保证on和emit用的是同一个K,联想绑定时的载荷类型就一定匹配。
实际使用起来效果很直观:
type AppEvents = EventEmitter<EventMap> declare const bus: AppEvents // 正确调用:载荷类型完全匹配 bus.on('user:login', ({ userId, token }) => { console.log(userId, token) }) // 编辑时会报错:缺少 token 属性 bus.emit('user:login', { userId: 'u1' })走到这一步,还没完。为了应对多个事件场景的重用,我需要一个工厂函数,让每种事件表都能生成独立的EventEmitter实例类型:
function createEmitter<T extends Record<string, any>>(): EventEmitter<T> { const handlersMap = new Map<keyof T, Set<Function>>() return { on(eventName, handler) { const handlers = handlersMap.get(eventName) ?? new Set() handlers.add(handler) handlersMap.set(eventName, handlers) }, emit(eventName, payload) { const handlers = handlersMap.get(eventName) if (!handlers) return handlers.forEach((handler) => handler(payload)) }, off(eventName, handler) { const handlers = handlersMap.get(eventName) if (!handlers) return handlers.delete(handler) }, } }这里有一个实操细节值得单独说:运行时实现里我用Map<keyof T, Set<Function>>来保存事件处理函数,而不是用Map<string, Function>。虽然两种写法在运行时几乎没有区别,但keyof T这个类型标注能让整个实现与类型推导保持语义一致,后续维护代码的人也能看出这个Map的键是受限的。类似这样的一点点类型约束,在大型项目里很影响可读性和安全性。
3.2 类型测试用例的写法
在跑类型测试之前,我用了一些工具链基础:tsd或者ts-expect-error配合Vitest的expectTypeOf。ts-expect-error这个指令是TS官方提供的,用于标注“下一行会有类型错误”。配合测试用例使用,能很好地验证正反两个方向。
以刚才的事件中心为例,测试用例大致是这样:
import { expectTypeOf } from 'vitest' type AppEvents = EventEmitter<EventMap> // 正向验证:on 的回调参数必须能匹配到 user:login 的载荷 expectTypeOf<AppEvents>().toMatchType<{ on: (eventName: 'user:login', handler: (payload: { userId: string; token: string }) => void) => void }>() // 反向验证:下面这行代码如果没有类型错误则说明我们的 on 限制太宽 // @ts-expect-error 载荷缺少 token 属性 bus.emit('user:login', { userId: 'u1' })有一点我想重点提醒:expectTypeOf并不保证类型的“强相等”,它有可能是“结构兼容匹配”,这在类型测试中会掩盖一些细微问题。所以,如果要验证精确相等,我会额外使用Equal辅助类型做等值判断,这种两段式验证在疑难问题上特别有用。
3.3 引入脚本跑全量测试
为了让这套题可以在本地一键运行,我写了一个简单的run-tests.ts脚本,用Vitest跑所有题目目录下的test-cases.ts。核心逻辑就是动态导入题目目录的测试文件:
// scripts/run-tests.ts import { readdirSync } from 'node:fs' import { resolve } from 'node:path' const challengesDir = resolve(__dirname, '../challenges') const challengeDirs = readdirSync(challengesDir) for (const dir of challengeDirs) { const testPath = resolve(challengesDir, dir, 'test-cases.ts') const mod = await import(testPath) console.log(`${dir}: 通过 ${mod.passCount} 个用例`) }实际用下来,这套脚本最大的价值不是跑测试,而是逼迫每个题目都写成“可自动验证”的格式。有些题目和测试用例如果不仔细,写的时候发现很多边界情况根本没法类型化表述——比如“这个字段必须是一个函数”。这种“无法表述的期望”,本身就是一个很深的知识点,能把题目重新打磨得更清晰。
3.4 性能排查实录
在做到高阶题的时候,我遇到过一个典型的性能问题。当时实现了一个DeepMerge类型,用来把两套深层配置合并,大概长这样:
type DeepMerge<A, B> = { [K in keyof A | keyof B]: K extends keyof A ? K extends keyof B ? A[K] extends object ? B[K] extends object ? DeepMerge<A[K], B[K]> : A[K] | B[K] : A[K] | B[K] : A[K] : K extends keyof B ? B[K] : never }语法上没有错误,但当我把一个非常庞大的配置类型丢进去时,TS语言服务直接卡顿,整个编辑器的自动补全延迟达到几秒。排查下来发现是嵌套条件类型太多,加上递归没有剪枝造成的。
后来我参考了一些社区做法,给递归加了一个“深度判断”:
type DeepMergeWithLimit<A, B, Depth extends number = 5> = Depth extends 0 ? A | B : { [K in keyof A | keyof B]: K extends keyof A ? K extends keyof B ? A[K] extends object ? B[K] extends object ? DeepMergeWithLimit<A[K], B[K], Subtract<Depth, 1>> : A[K] | B[K] : A[K] | B[K] : A[K] : K extends keyof B ? B[K] : never }加了一个Depth默认值,用Subtract减值。这样一来,既保留了深层合并的能力,又保证了TS类型检查不会无限递归。每次做完这类题目,我都会想一个问题:如果是队友遇到这个类型工具卡到编辑卡顿,他可能第一反应是删掉类型声明用any,而在团队协作中,这种“类型工具保护团队体验”的价值,往往比工具本身更值得被记录下来。
4. 常见问题与排查技巧实录
4.1 为什么extends判断和我的直觉不一样
很多初学者在练习时会遇到一个困惑:“string extends string | number的结果是true,这个好理解;但为什么{a: string} extends {}也是true?”这是TypeScript结构化类型系统的基础概念。在类型挑战的语境下,我常用的经验法则是:判断一个类型是否extends另一个类型,本质上是在问“前者能不能安全地当作后者使用”。一个{a: string}的对象,当然可以安全地当作空对象{}用,因此判断为true。但当你的条件是K extends keyof T时,这里限制的是字面量联合类型里的键集,是完全不同的维度。很多题做错,不是语法不行,而是脑子里没有把“可赋值性”和“相等性”区分开。
4.2 递归类型在复杂数据里“跑不动”
这里说的“跑不动”有两种:一种是编辑器明显卡顿,一种是类型检查报Type instantiation is excessively deep and possibly infinite。后者一般是因为递归没有终止条件,或者终止条件依赖于一个会不断变化的泛型参数,导致TS无法静态识别深度。我的排查顺序是:先检查终止条件是否放在最外层,再检查递归是否每次都会减少某个维度,最后考虑显式传入一个最大深度参数。如果都很正常,那就把类型拆成多个辅助类型,减少单个类型的复杂度。这条经验在对超大接口类型做DeepPartial时尤其有效。
4.3 用ts-expect-error时会踩的坑
ts-expect-error的本意是“下一行有类型错误”,但如果你把它放在一行没有类型错误的代码上面,TS反而会报一个“未被使用的错误指令”的编译错误。于是很多初学者会把代码改成“看起来有错但其实没有错”的样子来消除报错,反而掩盖了真正的问题。我的建议是:这个指令尽量配合精确的Equal辅助类型一起用,并且保证顶层有expectTypeOf验证正面语义。正反两向一起测,才能确认类型是正确的。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 条件判断结果不符合预期 | extends判的是可赋值性,不是相等性 | 用[T] extends [X]避免分发,或自定义Equal辅助 |
| 类型工具不会自动作用到联合类型 | 条件类型分发机制导致每个联合成员被单独计算后合并 | 需要“非分发”场景时用元组包裹[T],或用T extends any ? ... : never显式分发 |
| 递归开销大、编译卡顿 | 递归深度无上限 | 引入深度参数、剪枝、或拆分辅助类型 |
keyof拿到的是`number | string | symbol`混合键 |
| 泛型约束不生效(例如函数入参没被推导出预期类型) | 泛型默认推导可能选择宽泛类型 | 用K extends keyof T+T[K]的组合,或增加默认泛型 |
这张表我贴在项目README里,很多人反馈说价值比某几道难题还大,因为它能快速定位日常类型报错的排查方向。
4.5 测试用例里的expectTypeOf不等于Equal
在类型测试领域里,expectTypeOf默认是“结构兼容匹配”,意思是“只要目标类型的结构能接受源类型即可”。但有时候我们要的是“两者完全一样”,比如一个type A = {a: string}和一个type B = {a: string; b?: never},结构上几乎兼容,但语义上可能完全不同。我平时会在工具库里放一个Equal辅助类型,用于精确认证:
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false这个写法的核心是利用函数类型参数位置的逆变特性来比较两个类型是否完全一致。实际应用中,我主要用它来判断两个类型工具函数输出的结果是否和手写的期望值完全一致,只有这个能过,才能说明类型工具没有多余或遗漏的属性。
5. 项目收获与实际拓展方向
5.1 做完这套题后的最大体会
我个人做完这套系统性的练习后,最大的体会不是“我会写更多类型工具了”,而是“遇到类型报错时不慌了”。以前遇到一个复杂的类型推导报错,第一反应就是“是不是我写错了”,然后把能删的都删掉。现在遇到报错,我会尝试把复杂类型拆到最小复现,再用Equal辅助类型验证自己的假设。这种先建立假设、再动手验证的习惯,其实已经不局限于类型工具,而是渗透到日常所有调试过程中了。
而且,把这些题目组织成项目还有一个连锁好处:可以用它来做团队内部的类型进阶培训。把每一道题对应的“参考答案解析”直接打印出来当讲义,把测试用例当随堂练习,效果比单纯讲PPT要扎实得多。
5.2 这个项目还可以接着怎么扩展
目前“t3code”还可以继续扩展的方向有这几个:
第一是把业务实战部分扩充成一个“业务类型需求库”,持续收集日常开发中遇到的真实类型需求。比如分页响应类型、筛选条件类型、图表配置类型,每个人都能往里面贡献题目。
第二是把“类型工具的性能评估”做成一个可量化的测试。可以在CI里记录不同类型断言的时间,一旦某个类型工具导致编译时间超标就报警。这个目前实现起来有一些技术门槛,因为不同TS版本的类型检查性能差异挺大,但方向很值得尝试。
第三是做一个纯网页版的“类型挑战小游戏”,左侧题目、右侧编辑器、底部是类型测试结果。虽然市面上已经有一些类似的在线编码平台,但对于一个团队内部的知识库来说,把题目用自己的业务场景改写成团队专属版本,内部培训时会更贴合实际,氛围也好得多。
在做这套题的过程中,我还养成了一个习惯:每做完一个题目,都会顺手把题目中的类型工具提炼成一行注释保存到utils/type-tools.tips.ts里,当作团队公共注释。这算是一个小技巧,就是让这些题目真正“长”在日常开发里,而不是学完就丢。如果你也在写TypeScript类型进阶的练习,建议你也给自己建一个“类型工具弹药库”,等量变积累到一定程度,你会明显感觉到写类型的时候思路更顺畅,不再翻来覆去试错,而是一步到位。