类型体操(Type-Level Programming)是把 TypeScript 的类型系统当作一门独立的"编程语言"来使用。入门篇只解决了"会用内置工具类型",而进阶篇要回答一个更根本的问题:当内置工具类型不够用时,如何像写普通函数一样,用条件分支、递归、字符串拼接把复杂类型"计算"出来?
本文是 https://plumephp.com/typescript-advanced-types/ 的延续,聚焦于组合:单一特性解决不了的问题,靠多个特性的叠加解决。你会看到条件类型的分发语义如何被刻意抑制、模板字面量类型如何配合 infer 做字符串模式匹配、递归如何在一层层展开中构建 DeepPartial 这类"深度工具"。
1. 条件类型与分发机制的深度组合
1.1 裸类型参数的分发语义
条件类型的分发(Distribution)是所有组合技巧的地基。当一个泛型参数 T 以"裸"形式出现在 T extends U ? X : Y 中时,TypeScript 会把联合类型拆分、逐个求值、再合并成联合类型:
type FilterNullish<T> = T extends null | undefined ? never : T;
// 分发:string | number | null | undefined 被逐个求值
type Cleaned = FilterNullish<string | number | null | undefined>;
// => string | number
// 非分发版本(包裹成元组),联合类型被当作整体判断
type FilterNullishTuple<T> = [T] extends [null | undefined] ? never : T;
type CleanedTuple = FilterNullishTuple<string | number | null | undefined>;
// => string | number | null | undefined(没有分发,整体不匹配)
理解分发的关键是记住两个"触发条件":泛型参数必须是裸的(没有被元组、数组、其他泛型包裹),且必须直接出现在条件类型的左边。
1.2 用 IsEqual 做精确比较
T extends U 走的是可赋值性(Assignability),不是严格相等。string 可以被赋给 string | number,但二者显然不是同一个类型。很多工具类型需要"精确相等"语义,就必须用函数参数的双向逆变来模拟:
type IsEqual<A, B> =
(<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2)
? true
: false;
type Test1 = IsEqual<string, string>; // true
type Test2 = IsEqual<string, string | number>; // false(可赋值但不相等)
type Test3 = IsEqual<{ a: 1 }, { a: 1 }>; // true
这个技巧利用了"两个泛型函数签名是否互相可赋值"来判断 A 和 B 是否在同一位置触发相同分布,是社区公认的 IsEqual 标准实现。
1.3 识别联合类型:IsUnion
分发特性的一个推论是:只有当 T 是联合类型时,分发才会发生。因此可以反向利用这一点来判断一个类型是否为联合:
type IsUnion<T, U = T> =
T extends any
? [U] extends [T] // 对每个成员,检查整体 U 是否仍等于该成员
? false
: true
: never;
type A = IsUnion<string | number>; // true
type B = IsUnion<string>; // false
type C = IsUnion<never>; // false
注意 never 的特殊性:never 在条件类型中会触发空分发,因此 IsUnion<never> 需要单独处理,上面的实现通过外层分发天然规避了误判。
1.4 利用分发实现 UnionToIntersection
分发会把联合"拆开",而函数参数位置的反向分发可以把联合"合并"成交集——这是实现 UnionToIntersection 的经典套路,底层依赖逆变位置的联合到交集的转换规则:
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends
((k: infer I) => void) ? I : never;
type A = { a: 1 } | { b: 2 };
// 先变成 (k: {a:1}) => void | (k: {b:2}) => void
// 逆变位置:联合的入参需要同时满足 → 交集
type Result = UnionToIntersection<A>; // { a: 1 } & { b: 2 }
这在实现"把多个对象的属性合并成一个"的场景(例如混入 Mixin)中非常有用。
2. 映射类型与键操作
2.1 键重映射(Key Remapping)
TS 4.1 之后,映射类型支持 as 子句,可以改写键名。这一能力把映射类型从"遍历键"升级为"生成新键":
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { name: string; age: number };
type UserGetters = Getters<User>;
// => { getName: () => string; getAge: () => number }
Capitalize 是模板字面量类型内置的字符串工具,as 子句后可以跟任意类型表达式——这是映射类型最强大的扩展点。
2.2 过滤键:从 Pick 到 PickByValue
用 as 重映射 + never 键的技巧,可以按值类型过滤属性。Pick<T, K> 只能按已知键选取,而 PickByValue 能按属性值的类型选取:
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type Mixed = { id: number; name: string; createdAt: Date };
type OnlyStrings = PickByValue<Mixed, string>; // { name: string }
type OnlyDates = PickByValue<Mixed, Date>; // { createdAt: Date }
配合第 1 节的 IsEqual,还可以升级成"精确值匹配"的 PickByValueExact,避免 T[K] extends V 的子类型误判。
2.3 映射类型联合:EnumToRecord
把字符串枚举转换成 Record 类型是映射类型与键操作的又一个组合点:
enum LogLevel {
Debug = 'debug',
Info = 'info',
Warn = 'warn',
Error = 'error',
}
type LevelConfig = {
[K in LogLevel]: { label: string; color: string };
};
// 等价于手写:
// type LevelConfig = {
// debug: { label: string; color: string };
// info: { label: string; color: string };
// ...
// }
3. 模板字面量类型与字符串体操
3.1 字符串模式匹配
模板字面量类型可以配合 infer 做字符串的模式匹配,这是字符串层面的"正则"。最常见的应用是类型安全的路径参数提取:
type ExtractPathParams<Path extends string> =
Path extends `${infer _Start}/:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractPathParams<`/${Rest}`>]: string }
: Path extends `/:${infer Param}`
? { [K in Param]: string }
: {};
type Route = ExtractPathParams<'/users/:userId/posts/:postId'>;
// => { userId: string; postId: string }
3.2 大小写与格式转换工具
TS 内置 Uppercase、Lowercase、Capitalize、Uncapitalize,它们本身是模板字面量类型的内建实现。利用字符串 infer 可以组合出更复杂的分词工具,例如把 camelCase 转成 kebab-case:
type KebabCase<S extends string> =
S extends `${infer Head}${infer Tail}`
? Tail extends Uncapitalize<Tail>
? `${Uncapitalize<Head>}${KebabCase<Tail>}`
: `${Uncapitalize<Head>}-${KebabCase<Tail>}`
: S;
type A = KebabCase<'createUserProfile'>; // create-user-profile
type B = KebabCase<'getHTTPResponse'>; // get-h-t-t-p-response(对连续大写有边界)
注意
KebabCase<'getHTTPResponse'>的结果对连续大写的处理并不完美,这是模板字面量类型的已知边界。工程上通常配合@typescript-eslint的命名规范,从源头避免这类输入。
3.3 生成语义化联合类型:Brand 与字面量拼接
模板字面量类型最常见的生产用途,是从运行时对象推导出语义化字符串联合:
const routes = {
home: '/',
users: '/users',
userDetail: (id: number) => `/users/${id}`,
} as const;
type Routes = typeof routes;
type PathOf<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => string
? ReturnType<T[K]>
: T[K];
}[keyof T];
type AllPaths = PathOf<Routes>;
// => '/' | '/users' | string(ReturnType 的推导是宽泛的 string)
当函数返回的模板字面量类型能被精确推导(如 as const + 模板字符串函数声明)时,AllPaths 就能收敛为精确的字面量联合,从而在路由跳转时获得编译期路径校验。
4. infer 递归:构建 Deep 工具类型
4.1 DeepPartial 与 DeepReadonly
infer 递归是"深度工具类型"的核心。标准库的 Partial 只处理一层,而 DeepPartial 需要把递归条件 + 映射类型组合起来,让每一层嵌套对象都被递归处理:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? T[P] extends Function
? T[P]
: DeepPartial<T[P]>
: T[P];
};
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Function
? T[P]
: DeepReadonly<T[P]>
: T[P];
};
interface Config {
server: {
host: string;
tls: { enabled: boolean; certPath: string };
};
plugins: string[];
}
type PartialConfig = DeepPartial<Config>;
// 每一层属性都变为可选,且深层对象继续递归展开
const cfg: PartialConfig = { server: { tls: { enabled: true } } };
两个细节值得注意:
Function要显式排除,否则函数类型也会被当成object递归(DeepPartial会把方法变成可选的嵌套对象,破坏调用签名)。object的判断对Date、RegExp等类实例同样成立,因此它们也会被递归——这通常是期望行为,但若想保留原型方法,需要在实践中按需调整。
4.2 DeepRequired 与 DeepNonNullable
与 DeepPartial 对称,DeepRequired 要去掉每一层的可选修饰符,同时剔除 undefined:
type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object
? T[P] extends Function
? T[P]
: DeepRequired<T[P]>
: NonNullable<T[P]>;
};
type DeepNonNullable<T> = {
[P in keyof T]: T[P] extends object
? DeepNonNullable<NonNullable<T[P]>>
: NonNullable<T[P]>;
};
-? 修饰符在映射类型中表示"移除可选性",与 ?(添加可选性)相对;-readonly 则是移除只读。
4.3 递归处理数组与元组
深度工具类型还需要覆盖数组。T[P] extends object 对数组也成立(数组是对象),所以 DeepPartial<{ list: Item[] }> 会得到 DeepPartial<Item>[]——这通常是对的。但如果想递归数组元素本身,需要显式分发:
type DeepPartialArray<T> =
T extends (infer U)[]
? DeepPartialArray<U>[]
: T extends object
? { [P in keyof T]?: DeepPartialArray<T[P]> }
: T;
type Result = DeepPartialArray<{ items: { a: { b: number } }[] }>;
// => { items?: { a?: { b?: number } }[] }
这里先用 infer U 提取数组元素,递归后再套回 [];再对非数组对象走映射类型。分支顺序决定了数组优先被处理。
5. 函数重载解析与复杂工具组合
5.1 解析函数重载:OverloadParameters 与 OverloadReturnType
内置的 Parameters<T> 只能取函数最后一个重载的参数。当需要取全部重载签名时,需要把函数类型分发成联合再逐一提取:
type OverloadParameters<T extends (...args: any) => any> =
T extends { (...args: infer A): any } ? A : never;
type OverloadReturnType<T extends (...args: any) => any> =
T extends { (...args: any): infer R } ? R : never;
function find(id: string): User | undefined;
function find(id: number, extra?: boolean): User | undefined;
function find(id: string | number): User | undefined { /* ... */ }
type Params = OverloadParameters<typeof find>;
// => [id: string] | [id: number, extra?: boolean]
type Returns = OverloadReturnType<typeof find>; // User | undefined
关键点:T extends { (...args: infer A): any } 对每一个重载签名分别匹配,infer A 对每个签名提取,最后合成联合——这又是一个"分发"与"infer"的组合。
5.2 Curried 函数类型
柯里化类型是"递归 + 元组推断"的经典例题。给定一个接受元组的函数,推导它被部分应用后的签名:
type Curried<F extends (...args: any[]) => any> =
F extends (...args: infer Args) => infer R
? Args extends [infer First, ...infer Rest]
? Rest extends []
? F // 参数已全部接收
: (x: First) => Curried<(...rest: Rest) => R>
: () => R
: never;
function add(a: number, b: number): number { return a + b; }
type CurriedAdd = Curried<typeof add>;
// => (x: number) => (x: number) => number
5.3 组合实战:DeepPick 与 DeepOmit
把第 2 节(键重映射)与第 4 节(递归)组合,可以做出 Pick 的"深度版本"——按点分路径提取嵌套字段:
type DeepPick<T, Paths extends string> =
Paths extends `${infer K}.${infer Rest}`
? K extends keyof T
? { [P in K]: DeepPick<T[K], Rest> }
: never
: Paths extends keyof T
? { [P in Paths]: T[P] }
: never;
type Config = { server: { tls: { enabled: boolean } }; port: number };
type Picked = DeepPick<Config, 'server.tls'>;
// => { server: { tls: { enabled: boolean } } }
同理可以实现 DeepOmit(反向剔除嵌套字段)。这类工具在大型配置对象只暴露部分字段给特定模块、或做权限字段裁剪时非常实用。
6. 类型体操性能与工程最佳实践
6.1 递归深度与尾递归优化
TS 对条件类型的递归有实例化深度限制(默认 50,TS 4.5+ 支持尾递归消除)。深度递归工具类型要避免在"尾部以外"的位置递归,否则会触发 Type instantiation is excessively deep 错误:
// 尾递归位置(安全,TS 4.5+ 会优化)
type Repeat<T, N extends number, Acc extends any[] = []> =
Acc['length'] extends N ? Acc : Repeat<T, N, [T, ...Acc]>;
把累积结果放在最后一个参数(尾位置),TypeScript 会复用栈帧,避免实例化爆炸。
6.2 复杂度控制
类型计算是编译期成本。生产团队应有意识地做复杂度预算:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 只对"热路径"写体操 | 核心类型精雕细琢,边缘类型用简单表达 | API 路由、表单 schema |
用 interface 而非 type 做公开 API | interface 可声明合并、错误信息更友好 | 库的对外类型 |
| 限制递归层数 | 给递归加 Depth 参数 | Deep* 工具、路径类型 |
| 大类型拆小 | 每个工具类型保持单一职责 | 便于测试与复用 |
6.3 给递归工具加 Depth 上限
生产级 DeepPartial 通常需要防爆:
type DeepPartialLimited<T, Depth extends number = 5, Counter extends number[] = []> =
Counter['length'] extends Depth
? T
: {
[P in keyof T]?: T[P] extends object
? T[P] extends Function
? T[P]
: DeepPartialLimited<T[P], Depth, [0, ...Counter]>
: T[P];
};
Counter 元组每递归一层就追加一个元素,当 Counter['length'] 达到 Depth 时停止递归。这为"不可信/极深"的输入提供了编译期兜底。
6.4 用类型测试锁定行为
类型体操代码也是"代码",需要回归测试。推荐用 tsd 或 vitest 的 expectTypeOf 做类型断言:
// vitest + expectTypeOf
import { describe, it, expectTypeOf } from 'vitest';
type DeepPartial<T> = /* ... */;
it('DeepPartial 对嵌套对象逐层生效', () => {
type Input = { a: { b: { c: number } } };
expectTypeOf<DeepPartial<Input>>().toEqualTypeOf<{
a?: { b?: { c?: number } };
}>();
});
7. 总结与进阶路径
类型体操的价值不在"炫技",而在把运行时才能发现的问题前移到编译期。本文覆盖的组合能力可以归纳为一张能力地图:
| 组合能力 | 基础构件 | 典型产物 |
|---|---|---|
| 分发控制 | 裸参数 / 元组包裹 / IsEqual | PickByValue、UnionToIntersection、IsUnion |
| 键变换 | 映射类型 + as 子句 | Getters、PickByValue、键前缀改写 |
| 字符串匹配 | 模板字面量 + infer | ExtractPathParams、KebabCase |
| 深度递归 | 条件类型 + 映射类型 + infer | DeepPartial、DeepReadonly、DeepPick |
| 函数重载 | 分发 + infer | OverloadParameters、Curried |
下一步可以深入:
- 在 https://plumephp.com/typescript-runtime-validation-typesafe/ 中看到类型体操如何与运行时验证(Zod)结合,用
z.infer让 schema 与类型同源; - 在 https://plumephp.com/typescript-project-architecture-tsconfig/ 中了解大型项目如何配置
paths与项目引用,为这些工具类型建立可维护的工程底座; - 在 https://plumephp.com/typescript-decorators-metaprogramming/ 中探索装饰器与元编程,体验"类型之上再叠一层元数据"的系统级抽象。
类型体操的边界由编译器性能与团队可读性共同决定。能用简单类型表达的,不要写体操;体操只用在"收益显著"的公共边界——这是最值得牢记的一条最佳实践。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。