TypeScript 类型体操:从入门到写一个类型安全的计算器
- 2026-05-15
- Typescript 前端开发
- advanced types generics type challenges typescript
TypeScript 类型体操:从入门到写一个类型安全的计算器
类型不是约束,是另一种编程语言。学会用它表达逻辑,你的 TypeScript 就会进入下一个层次。
为什么需要类型体操?
先看一个真实场景:你在写一个缓存工具,键值对的类型需要在编译时就精确关联。
const cache = new TypedCache()
cache.set('name', '张三')
cache.set('age', 25)
const name = cache.get('name') // 期望类型: string ✅
const age = cache.get('age') // 期望类型: number ✅
const x = cache.get('xxx') // 期望类型: undefined ❌ 这应该是编译错误
用常规 TypeScript 做不到。你只能声明 get(key: string): unknown,然后到处手动断言。类型体操就是解决这类问题的——当现有类型系统不够用时,用泛型、条件类型、递归等手段创造精确的类型约束。
类型体操的核心思想:把类型看作函数。
- 泛型参数 = 函数的输入
- 条件类型 = 函数的
if/else - 递归 = 函数的循环
infer= 函数的模式匹配 / 解构
理解了这四个要素,类型系统就是图灵完备的编程语言,只不过它运行在编译期。
基础三件套:泛型、条件类型、infer
泛型参数
写过 Array<T> 就是入门了。但类型体操里的泛型更像函数的参数列表——可以有默认值、约束、变长参数。
// 一个「函数」,输入 T,输出 T 包装成数组
type WrapInArray<T> = T extends unknown ? T[] : never
type A = WrapInArray<string> // string[]
type B = WrapInArray<number> // number[]
条件类型
extends 在这里不是「继承」,而是「属于」或「可赋值给」。
type IsString<T> = T extends string ? true : false
type R1 = IsString<'hello'> // true
type R2 = IsString<42> // false
三目运算符的链式写法也成立:
type TypeName<T> =
T extends string ? 'string' :
T extends number ? 'number' :
T extends boolean ? 'boolean' :
'other'
type N = TypeName<true> // 'boolean'
infer 关键字
infer 是从一个已知结构里「提取」出某个部分的类型。类似解构赋值,只不过在类型层面。
// 提取数组元素类型
type ElementType<T> = T extends (infer U)[] ? U : never
type E = ElementType<string[]> // string
// 提取函数返回值类型
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never
type Fn = (x: number) => string
type R = ReturnOf<Fn> // string
infer 的典型使用场景:你想获得某个复杂类型的内部片段,直接取又写不出来,就用 infer 让 TypeScript 自己推导。
实战一:实现一个类型安全的 Promise.all
理解了基础,看一个实际有用的例子。面试经常考,工作也真的用得上。
// 目标:传入 ['a', 1, true] 这种 tuple,推导出 [string, number, boolean]
// 而不是 (string | number | boolean)[]
type PromiseAll<T extends readonly unknown[]> = {
[K in keyof T]: T[K] extends Promise<infer V> ? V : T[K]
}
declare function PromiseAll<T extends readonly unknown[]>(
values: readonly [...T]
): PromiseAll<T>
// 验证
const result = PromiseAll([Promise.resolve('a'), Promise.resolve(1), true])
// result: Promise<[string, number, boolean]>
这里做了两件事:
- 用
[...T]的变长元组写法保留输入的顺序和精确类型 - 用映射类型
[K in keyof T]遍历元组,对每一项用infer解包 Promise
进阶:类型级别的算术运算
JavaScript 的类型系统不直接支持算术。但我们可以用「元组长度即数字」这个技巧来实现——因为 TypeScript 允许递归构造元组。
核心思想
数字 N 对应长度为 N 的元组。
type BuildTuple<N extends number, Acc extends unknown[] = []> =
Acc['length'] extends N
? Acc
: BuildTuple<N, [...Acc, unknown]>
type Three = BuildTuple<3> // [unknown, unknown, unknown]
type LengthOf<T extends unknown[]> = T['length']
type ThreeLen = LengthOf<BuildTuple<3>> // 3
加法
type Add<A extends number, B extends number> = [
...BuildTuple<A>,
...BuildTuple<B>
]['length']
type Sum = Add<3, 5> // 8
减法(借用的思想)
type Subtract<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer R]
? R['length']
: never
type Diff = Subtract<7, 3> // 4
这里的关键:如果 A 的元组可以由「B 的元组拼接上剩余部分 R」构成,R 的长度就是 A - B。
比较
type GreaterThan<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer _]
? true
: false
type G1 = GreaterThan<10, 3> // true
type G2 = GreaterThan<2, 5> // false
乘法
type Mul<A extends number, B extends number, Acc extends unknown[] = []> =
Acc['length'] extends B
? []
: [...BuildTuple<A>, ...Mul<A, B, [...Acc, unknown]>]
type Product = Mul<3, 4>['length'] // 12
乘法就是「做 B 次加法」。用累加器 Acc 计数,到 B 时终止递归。
除法
type Div<A extends number, B extends number, Acc extends unknown[] = []> =
GreaterThan<A, B> extends false
? Acc['length']
: Div<Subtract<A, B>, B, [...Acc, unknown]>
type Quotient = Div<15, 4> // 3 (整除)
除法就是「反复减 B,直到小于 B」,减了多少次就是商。
实战二:完整类型计算器
把上面的运算组合起来,定义一个通用的计算器类型:
interface CalcExpr {
op: '+' | '-' | '*' | '/'
left: number
right: number
}
type EvalCalc<T extends CalcExpr> =
T['op'] extends '+' ? Add<T['left'], T['right']> :
T['op'] extends '-' ? Subtract<T['left'], T['right']> :
T['op'] extends '*' ? Mul<T['left'], T['right']>['length'] :
T['op'] extends '/' ? Div<T['left'], T['right']> :
never
type R = EvalCalc<{ op: '+'; left: 7; right: 5 }> // 12
type S = EvalCalc<{ op: '*'; left: 6; right: 7 }> // 42
这段代码在编译期就完成了计算——运行时根本不存在这段逻辑,最终产物里是纯数值。这就是「类型体操把计算移到编译期」的直观例子。
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手动类型断言 | 简单粗暴,零心智负担 | 类型不安全,容易漏改 | 快速原型 |
| 基础泛型约束 | 安全,易读 | 表达能力有限 | 日常 CRUD |
| 条件类型 + infer | 能解构复杂类型 | 嵌套深时可读性差 | 中间件、类型提取 |
| Tuple 算术 | 编译期绝对安全,能做复杂逻辑 | 大量递归时类型检查慢,数字有上限(~1000) | 配置计算、模板引擎、类型验证 |
当心递归深度
TypeScript 默认递归深度限制在 ~45-50 层(取决于版本),可以通过 --typeRecursionLimit 提升,但不建议在生产中写超过几百层的递归类型。Tuple 算术的数值上限大约在 1000 左右,太小了就别用这种方案了。
总结
类型体操不是什么炫技。它的本质是在编译期做更多事,减少运行时的防御代码和人为失误。
三个要点值得记住:
infer是类型版的解构赋值——不会用 infer,遇到复杂类型提取就只能写any。学会它,就能把Promise<Promise<string>>安全地提成string。- Tuple 长度即数字——当需要类型级别的算术时,这是最直观的途径。有更优雅的方案(模板字符串解析),但 Tuple 方案最容易理解。
- 递归是必然的——类型系统没有 for 循环,所有重复操作都得靠递归写。控制好深度,必要时用尾递归优化。
如果想上手练习,推荐 type-challenges 这个仓库,从 Pick、Readonly 开始刷,很快就能建立感觉。