类型体操:把类型当成程序
TypeScript 的迷人之处在于:它的类型系统不是一堆标签,而是一台能「运行」的迷你计算机。你可以写条件分支、做递归、拼接字符串、遍历对象结构——只不过处理的对象不是运行时的数据,而是编译期的类型。这套玩法在社区里有个形象的名字:类型体操(Type Gymnastics)。
为什么要练这套体操?因为它能把「编译期就能保证的约束」写进代码里。比如「这个函数接收一个配置对象,返回一个只有可写字段的版本」「这个事件处理器要求参数的类型和事件名严格匹配」,用类型体操写出来,写错就编译不过,根本轮不到运行时才发现。
类型体操不是炫技。像 Partial、Pick、ReturnType 这些天天在用的工具类型,底层全是同一套机制:泛型 + 条件类型 + 映射。把它们拆开看明白,你才算真正掌握了 TypeScript,而不是只会给变量标类型。
类型系统也是代码,只不过它运行在编译期、且永远不崩——它只会在你写错时大声报错。
- 条件类型
T extends U ? A : B的类型级 if。 - infer在条件类型里声明并提取子类型。
- 映射类型遍历对象键,逐键变换属性。
- 模板字面量在类型层拼接字符串字面量。
- 分发条件类型对联合类型的自动拆分。
泛型与条件类型
泛型是类型体操的「参数」。条件类型则是「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"} 会自动拆成两个字符串。它和后面的分发机制一起,构成了字符串类型运算的基础设施。
分发条件类型与工具类型
条件类型有个容易踩坑、但也极有用的特性:分发。当条件类型 T extends U ? A : B 中的 T 是裸的泛型参数、且传入的是联合类型时,TypeScript 会把联合类型拆开、逐个判断再合并结果。
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
// 想保留整体,就用 [] 包住 T:
type NoDist<T> = [T] extends [unknown] ? T[] : never;
type B = NoDist<string | number>; // (string | number)[]
分发能帮我们实现「对联合的每个成员做变换」,是体操里常用的技巧。比如把联合类型转成对象键:T extends infer U ? { [K in U]: K } : never 之类。
理解了分发、映射、infer 之后,再看标准库工具类型就全是老朋友:ReturnType 是 infer 提取,Record<K, V> 是映射,Exclude<T, U> 是条件加分发,Parameters 是 infer 提参数。工具类型不是需要背的魔法,而是一套可以自由组合的积木。
实战:实现 DeepReadonly 与类型级 Split
最后用两道经典题收尾。第一题 DeepReadonly:把对象每一层属性都变成只读,包括嵌套对象。
type DeepReadonly<T> =
T extends (infer E)[] ? ReadonlyArray<DeepReadonly<E>> :
T extends Function ? T :
T extends object ? {
readonly [K in keyof T]: DeepReadonly<T[K]>
} : T;
interface Profile {
user: { name: string; addr: { city: string } };
tags: string[];
}
type R = DeepReadonly<Profile>; // 全层级只读
思路是递归:基本类型原样返回,函数原样返回,数组递归到元素,对象用映射类型逐键递归。写熟这题,映射 + 递归 + 条件类型的组合就过关了。
第二题类型级 Split:把字符串按分隔符拆成元组。
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}`
? [Head, ...Split<Tail, D>]
: [S];
type Parts = Split<"a,b,c", ",">; // ["a", "b", "c"]
用模板字面量匹配到第一个分隔符,拆出头尾,再对尾部递归。运行时写这个要循环,类型层写这个要递归——思维模型完全一致,只是舞台换到了编译期。
类型体操练的不是花活,而是把「约束前置」的能力:让错误在编译期现形,而不是在线上爆炸。