沙漠行星技术笔记 · 第 1 轨道
蓝巨星 B0 Ia · 轨道半径 6.1 万 km · 1 颗卫星
TypeScript 类型体操小记
条件类型、infer、模板字面量类型……几个真正在项目里用得上的类型技巧,以及什么时候应该停手。
- 发布
- 字数
- 624
- 阅读
- 2 分钟
TypeScript 的类型系统是图灵完备的,这意味着你可以在类型层面“编程”。社区把这种玩法戏称为类型体操。大部分体操动作只是炫技,但有一小部分确实能让日常代码更安全。
从一个真实需求开始
假设我们有一个事件总线,希望 emit 的参数类型能跟事件名自动对应:
interface Events {
'planet:click': { slug: string }
'camera:arrive': { target: string; distance: number }
'audio:toggle': boolean
}
function emit<K extends keyof Events>(name: K, payload: Events[K]) {
// ...
}
emit('planet:click', { slug: 'hello-universe' }) // ✅
emit('audio:toggle', 'yes') // ❌ 类型“string”的参数不能赋给类型“boolean”的参数这里用到的只是泛型约束和索引访问类型,却已经把一整类拼写错误和参数错误挡在了编译期。
条件类型与 infer
条件类型长得像三元表达式:A extends B ? X : Y。配合 infer,可以从一个类型里“解构”出另一部分:
type Awaited2<T> = T extends Promise<infer U> ? Awaited2<U> : T
type A = Awaited2<Promise<Promise<number>>> // number一个更实用的例子——取出函数第一个参数的类型:
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never
type P = FirstArg<typeof emit> // keyof Events模板字面量类型
TypeScript 4.1 之后,字符串也可以在类型层面拼接和拆分:
type Namespace<T extends string> = T extends `${infer NS}:${string}` ? NS : never
type NS = Namespace<keyof Events> // "planet" | "camera" | "audio"这让“根据字符串约定推导类型”成为可能,很多路由库就是靠它推导出路径参数的类型。
映射类型:批量改造
type Handlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: (payload: T[K]) => void
}
type H = Handlers<{ click: MouseEvent; resize: UIEvent }>
// { onClick: (payload: MouseEvent) => void; onResize: (payload: UIEvent) => void }as 子句可以在映射时重命名键,这是写配置对象类型时的利器。
什么时候应该停手
我给自己定了三条规矩:
- 类型报错信息看不懂的时候。如果同事需要五分钟才能读懂一条报错,这个类型就太复杂了。
- 需要递归超过三层的时候。编译器会变慢,而且递归深度有上限。
- 运行时校验更合适的时候。来自网络、文件的数据,类型再精巧也保证不了真实形状,这时应该用 Zod 之类的库在运行时校验。
类型是写给人看的文档,顺便让编译器帮你检查。
类型体操很好玩,但最好的类型,往往是那个让人一眼就能看懂的类型。