TypeScript 的类型系统可以根据输入类型计算出另一个类型。这里的“类型体操”通常指把条件类型、映射类型、infer、模板字面量类型和递归组合起来,给函数、配置对象和 API 增加静态约束。
这些技巧只在编译期间运行,不会生成 JavaScript。复杂类型的价值在于让调用处更早报错;如果一个类型工具难以解释或错误信息难以阅读,通常应该改用更直接的接口设计。
先准备几个基础积木
keyof 把对象类型转为属性名联合,索引访问类型 T[K] 则取回属性类型。
type User = {
id: number;
name: string;
active?: boolean;
};
type UserKey = keyof User; // "id" | "name" | "active"
type UserName = User["name"]; // string
type IdOrName = User["id" | "name"]; // number | string泛型约束可以让两个类型参数建立关系。下面的 get 只接受 obj 上确实存在的属性名,返回值也会随属性名推导。
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Ada" };
const name = get(user, "name"); // string
// get(user, "email"); // 编译错误条件类型与分发
条件类型的形式是 T extends U ? X : Y。当 T 是裸类型参数并且传入联合类型时,TypeScript 会逐个成员计算,再把结果合并为联合类型。
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>; // string[] | number[]这正是内置 Exclude 和 Extract 的实现思路。
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type Event = "click" | "focus" | "keydown";
type MouseEvent = MyExtract<Event, "click">; // "click"
type KeyboardEvent = MyExclude<Event, "click" | "focus">; // "keydown"never 在联合类型中会被消去,因此适合表示“过滤掉当前分支”。如果不希望分发,用元组包住两边:[T] extends [U] ? X : Y。
type IsString<T> = [T] extends [string] ? true : false;
type A = IsString<string | number>; // false,而不是 true | false用 infer 拆解类型
infer 只能出现在条件类型的 extends 子句中,用来声明待推断的类型变量。它最常见的用途是提取函数返回值、参数和 Promise 内部值。
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type Handler = (id: number, verbose: boolean) => Promise<string>;
type Return = MyReturnType<Handler>; // Promise<string>
type Args = MyParameters<Handler>; // [id: number, verbose: boolean]递归地解开 Promise 可以得到类似内置 Awaited 的效果:
type UnwrapPromise<T> = T extends Promise<infer Value>
? UnwrapPromise<Value>
: T;
type Value = UnwrapPromise<Promise<Promise<number>>>; // number元组同样可以被模式匹配。Head 取第一个元素,Tail 取剩余元素。
type Head<T extends readonly unknown[]> = T extends readonly [infer First, ...unknown[]]
? First
: never;
type Tail<T extends readonly unknown[]> = T extends readonly [unknown, ...infer Rest]
? Rest
: [];
type First = Head<["a", "b", "c"]>; // "a"
type Rest = Tail<["a", "b", "c"]>; // ["b", "c"]映射类型:批量修改属性
映射类型会遍历 keyof T 中的每个键。? 和 readonly 前面的 +、- 可以增加或移除修饰符。
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
type RequiredUser = MyRequired<User>;
type MutableUser = Mutable<Readonly<User>>;Pick、Omit 可以控制保留哪些键。Omit 的底层就是先排除键,再映射回对象。
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type MyOmit<T, K extends PropertyKey> = {
[P in Exclude<keyof T, K>]: T[P];
};
type UserPreview = MyPick<User, "id" | "name">;
type UserWithoutActive = MyOmit<User, "active">;映射类型还支持键重映射。下面把 User 的字段变成一组 getter 方法:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getActive: () => boolean | undefined;
// }string & K 用于排除 symbol 等不能放进模板字面量的键。也可以在 as 后返回 never,以过滤属性:
type OnlyMethods<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
};模板字面量类型:约束字符串格式
模板字面量类型把字符串联合做组合。它适合描述事件名、路径和对象字段派生出的字符串。
type Vertical = "top" | "bottom";
type Horizontal = "left" | "right";
type Position = `${Vertical}-${Horizontal}`;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"配合 infer 可以解析固定格式。下面从 "user:42" 中取出前缀和内容:
type SplitOnce<S extends string> = S extends `${infer Left}:${infer Right}`
? [Left, Right]
: [S];
type Parts = SplitOnce<"user:42">; // ["user", "42"]模板字面量类型不能验证任意正则表达式,适合结构固定、分隔符明确的字符串。运行时输入仍需进行实际校验。
递归类型:深层 Partial 与路径
递归类型会再次引用自身。最常用的例子是 DeepPartial,用于描述嵌套配置的局部更新。数组和函数需要单独处理,否则会把数组方法也映射进去。
type DeepPartial<T> = T extends (...args: any[]) => any
? T
: T extends readonly (infer Item)[]
? readonly DeepPartial<Item>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type Config = {
server: { host: string; ports: number[] };
retry: { count: number };
};
const patch: DeepPartial<Config> = {
server: { ports: [8080] },
};还可以递归生成对象路径。这个版本只处理字符串键,结果可以用于限定查询字段或表单字段名。
type Join<K extends string, P> = P extends string ? `${K}.${P}` : never;
type Paths<T> = T extends object
? {
[K in keyof T & string]: K | Join<K, Paths<T[K]>>;
}[keyof T & string]
: never;
type ConfigPath = Paths<Config>;
// "server" | "server.host" | "server.ports" | "retry" | "retry.count"递归层数和联合成员越多,类型检查成本越高,也可能得到难读的错误信息。对外暴露的工具类型应限制输入形状;对深层路径这类类型,必要时增加最大递归深度。
联合类型与交叉类型的转换
有些库会把联合转换成交叉,以便组合多个函数签名或对象约束。这个写法依赖条件类型对联合的分发:每个成员先被放入函数参数位置,再从参数的交叉位置推导出来。
type UnionToIntersection<U> = (
U extends unknown ? (value: U) => void : never
) extends (value: infer I) => void
? I
: never;
type Combined = UnionToIntersection<{ a: string } | { b: number }>;
// { a: string } & { b: number }这类类型依赖函数参数的逆变推导,维护成本较高。只有在确实需要同时满足联合中每个成员的约束时使用,不应把它当作普通对象合并工具。
实用操作总结
- 先写运行时 API,再从参数和返回值推导类型。类型工具应该服务于已有的数据结构。
- 给通用类型准备最小示例,并在编辑器悬停检查最终结果。
type Debug<T> = { [K in keyof T]: T[K] }常用于展开交叉类型的显示。 - 区分编译期约束和运行时校验。
as、模板字面量类型和泛型不能验证来自网络或用户输入的数据。 - 优先使用内置
Partial、Required、Readonly、Pick、Omit、Exclude、Extract、Parameters、ReturnType与Awaited。自定义类型应表达项目中特有的规则。
类型体操的重点是把数据结构之间的关系写进类型系统:从函数签名提取信息、批量变换对象属性、过滤联合成员,并在边界明确的情况下递归处理嵌套结构。超过这个范围的复杂类型需要评估可读性和类型检查开销。

