类型体操:把类型当成程序

TypeScript 的迷人之处在于:它的类型系统不是一堆标签,而是一台能「运行」的迷你计算机。你可以写条件分支、做递归、拼接字符串、遍历对象结构——只不过处理的对象不是运行时的数据,而是编译期的类型。这套玩法在社区里有个形象的名字:类型体操(Type Gymnastics)。

为什么要练这套体操?因为它能把「编译期就能保证的约束」写进代码里。比如「这个函数接收一个配置对象,返回一个只有可写字段的版本」「这个事件处理器要求参数的类型和事件名严格匹配」,用类型体操写出来,写错就编译不过,根本轮不到运行时才发现。

类型体操不是炫技。像 Partial、Pick、ReturnType 这些天天在用的工具类型,底层全是同一套机制:泛型 + 条件类型 + 映射。把它们拆开看明白,你才算真正掌握了 TypeScript,而不是只会给变量标类型。

类型系统也是代码,只不过它运行在编译期、且永远不崩——它只会在你写错时大声报错。
本文核心知识点
  • 条件类型T extends U ? A : B 的类型级 if。
  • infer在条件类型里声明并提取子类型。
  • 映射类型遍历对象键,逐键变换属性。
  • 模板字面量在类型层拼接字符串字面量。
  • 分发条件类型对联合类型的自动拆分。
TypeScript 类型体操实战配图:蓝紫渐变背景,中央一个发光的六边形拼图框架,内嵌三角形、圆形与菱形模块,右侧发光轨道与漏斗变换装置沿对角线延伸,方块经变换后统一输出
六边形外壳里嵌着不同形状的模块,经过变换装置统一输出——类型体操就是这种「形状级」的运算。

泛型与条件类型

泛型是类型体操的「参数」。条件类型则是「if 语句」,写法是 T extends U ? X : Y:如果类型 T 能赋值给 U,结果就是 X,否则是 Y。

type IsString<T> = T extends string ? true : false;

type A = IsString<"hi">;   // true
type B = IsString<42>;     // false
type C = IsString<never>;  // never(稍后解释)

注意条件类型判断的是「可赋值性」而不是「相等性」。"hi" extends string 成立,因为字符串字面量类型可以赋值给 string;反过来 string extends "hi" 不成立。这个方向感很重要,很多类型题做错都是因为把 extends 当成了等号。

条件类型还可以链式嵌套,组合出多分支逻辑,就像多个 if-else。加上递归之后,它甚至能在类型层完成循环——这是体操里最烧脑、也最有用的部分。

infer:从类型里提取类型

infer 是条件类型里的「模式匹配」:在一个已知结构里声明一个占位类型变量,让 TypeScript 去推导它的具体值。最经典的例子是取出函数返回类型:

type MyReturnType<T> =
    T extends (...args: any[]) => infer R ? R : never;

type F = () => number;
type R = MyReturnType<F>;   // number

拆开看:T extends (...args: any[]) => infer R 的意思是「如果 T 是一个函数类型,那么把它的返回值声明为 R」。infer 不能单独出现,必须放在条件类型的 extends 分支里,而且通常放在结构中的某个位置——返回值、参数、数组元素、Promise 的包裹类型,都可以提取。

type ElementOf<T> = T extends (infer E)[] ? E : never;
type S = ElementOf<string[]>;  // string

type Unwrap<T> = T extends Promise<infer U> ? U : T;
type N = Unwrap<Promise<number>>; // number
type M = Unwrap<string>;          // string

infer 配合递归可以处理嵌套结构:Promise 里套 Promise,一路 unwrap 到底。这就像写一个只在类型层存在的递归函数,每一层剥掉一层壳。

映射类型与 keyof

keyof T 取出对象类型所有键的联合,映射类型则用 in 语法遍历这些键、对每个属性做变换。最直观的例子是把对象所有属性变成只读或可选:

type MyReadonly<T> = {
    readonly [K in keyof T]: T[K]
};

type MyPartial<T> = {
    [K in keyof T]?: T[K]
};

interface User { name: string; age: number }
type RO = MyReadonly<User>;   // 所有属性只读
type PA = MyPartial<User>;    // 所有属性可选

映射类型是「按对象结构生成新对象」的通用引擎。你可以用 as 重映射键名,可以过滤键,可以递归深入每一层属性——DeepReadonly 就是这样一层层套出来的。

记住一个判断:如果你要「造一个和原来结构相似、但属性被加工过」的类型,八成要用映射类型;如果你要「根据输入类型决定输出类型」,多半是条件类型。两者经常组合使用。

模板字面量类型

模板字面量类型让类型也能拼接字符串,配合字符串字面量联合类型,能做很多「字符串级别的模式匹配」:

type EventName = `on${"click" | "hover" | "focus"}`;
// 等价于 "onclick" | "onhover" | "onfocus"

type ExtractPrefix<S extends string> =
    S extends `on${infer P}` ? P : never;

type E = ExtractPrefix<"onclick">;   // "click"
type F = ExtractPrefix<"mouse">;     // never

这套能力让事件系统、路由、状态机这类「名字有约定结构」的场景,可以在编译期就做严格的类型检查。你注册一个 onclick 处理器,签名推导出 click 事件的数据类型;写错事件名,直接编译报错。

模板字面量类型还支持对联合类型展开:on${"click" | "hover"} 会自动拆成两个字符串。它和后面的分发机制一起,构成了字符串类型运算的基础设施。