加载中...
文章封面图

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]>

这里做了两件事:

  1. [...T] 的变长元组写法保留输入的顺序和精确类型
  2. 用映射类型 [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 这个仓库,从 PickReadonly 开始刷,很快就能建立感觉。

Saber Kurama
Saber Kurama
© 2025 by SaberKurama Blog 本文基于 CC BY-NC-SA 4.0 许可 CC 协议 必须注明创作者 仅允许将作品用于非商业用途 改编作品必须遵循相同条款进行共享 最后更新:2026/5/15