TypeScript 高级特性与类型体操
TypeScript 高级特性与类型体操
TypeScript 的类型系统非常强大,强大到本身就是一门图灵完备的编程语言——只不过它运行在"编译期",操作的对象不是值,而是类型。所谓"类型体操"(Type Gymnastics),就是用条件类型、映射类型、递归等手段,在类型层面写"程序",让编译器帮你自动推导出精确的类型。
打个比方:普通编程是"给定输入值,算出输出值";类型体操是"给定输入类型,算出输出类型"。两者的思维方式高度相似,只是运行时机和数据形态不同。
为什么需要类型体操
有人质疑类型体操是"炫技",但它在真实工程里有实打实的价值:
代价是:过度复杂的类型会拖慢编译速度、降低可读性。能力越强,越要克制。
条件类型
条件类型是类型层面的三元运算符,是几乎所有高级类型的基础。
语法: `T extends U ? X : Y`——如果类型 T 可以赋值给 U,则结果为 X,否则为 Y。
// 基础条件类型
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
// 分布式条件类型:作用于联合类型时会"逐个分发"
type ToArray<T> = T extends any ? T[] : never;
type C = ToArray<string | number>; // string[] | number[]
// 用它实现内置的 Exclude
type MyExclude<T, U> = T extends U ? never : T;
type D = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'infer 关键字是条件类型的灵魂,它能在 `extends` 子句中"捕获"一个类型并起个名字,供分支使用。
// 提取函数返回值类型(内置 ReturnType 的实现)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => string>; // string
// 提取函数第一个参数类型
type FirstParam<T> = T extends (first: infer P, ...rest: any[]) => any ? P : never;
type R2 = FirstParam<(id: number, name: string) => void>; // number
// 提取数组元素类型
type ElementType<T> = T extends (infer U)[] ? U : never;
type R3 = ElementType<boolean[]>; // boolean
// 解包 Promise(内置 Awaited 的简化版)
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type R4 = Unwrap<Promise<number>>; // number映射类型
映射类型用于"遍历一个类型的所有属性并批量转换",是从已有类型派生新类型的核心工具。
语法: `{ [P in K]: T }`
interface User {
id: number;
name: string;
email: string;
}
// 把所有属性变可选(内置 Partial 的实现)
type MyPartial<T> = { [P in keyof T]?: T[P] };
// 把所有属性变只读(内置 Readonly 的实现)
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };
// 挑选部分属性(内置 Pick 的实现)
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type UserPreview = MyPick<User, 'id' | 'name'>;映射修饰符可以增删 `readonly` 和 `?`,用 `-` 前缀表示"移除"。
// 移除只读修饰符
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
// 移除可选修饰符(内置 Required 的实现)
type MyRequired<T> = { [P in keyof T]-?: T[P] };键重映射(Key Remapping)用 `as` 关键字重新命名键,配合模板字面量类型威力巨大。
// 为每个属性生成一个 getter 方法名
type Getters<T> = {
[P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};
type UserGetters = Getters<User>;
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// }
// 用 never 过滤掉不想要的键
type RemoveField<T, K> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
type WithoutId = RemoveField<User, 'id'>; // { name: string; email: string }模板字面量类型
模板字面量类型让你能在类型层面拼接、解析字符串,语法和 JavaScript 模板字符串一模一样:`${T}`。
type World = 'World';
type Greeting = `Hello ${World}`; // 'Hello World'
// 与联合类型组合会做"笛卡尔积"
type Color = 'red' | 'blue';
type Size = 'small' | 'large';
type Variant = `${Size}-${Color}`;
// 'small-red' | 'small-blue' | 'large-red' | 'large-blue'
// 构建类型安全的事件名
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickHandler = EventName<'click'>; // 'onClick'
// 构建 API 路径类型
type ApiRoute = `/api/${'users' | 'posts'}/${number}`;
const route: ApiRoute = '/api/users/1'; // OK内置字符串操作类型:
| 工具类型 | 作用 | 示例 |
| --- | --- | --- |
| Uppercase | 全部大写 | Uppercase 'abc' 得到 'ABC' |
| Lowercase | 全部小写 | Lowercase 'ABC' 得到 'abc' |
| Capitalize | 首字母大写 | Capitalize 'abc' 得到 'Abc' |
| Uncapitalize | 首字母小写 | Uncapitalize 'Abc' 得到 'abc' |
可以用 `infer` 在模板字面量里"解析"字符串:
// 提取路径参数
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<'/users/:id/posts/:postId'>;
// 'id' | 'postId'递归类型
类型可以引用自身,从而处理任意深度的嵌套结构。这是实现"深度"工具类型的关键。
// 深度只读
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
// 深度可选
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
interface Config {
server: { host: string; port: number };
db: { url: string; pool: { min: number; max: number } };
}
type PartialConfig = DeepPartial<Config>;
// 所有层级的属性都变成可选
const cfg: PartialConfig = { server: { port: 8080 } }; // OK
// 递归定义 JSON 值类型
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };类型体操综合实战
下面用类型体操实现几个 type-challenges 里的经典题目。
// 1. 元组转对象
type TupleToObject<T extends readonly PropertyKey[]> = {
[K in T[number]]: K;
};
type Obj = TupleToObject<['a', 'b']>; // { a: 'a'; b: 'b' }
// 2. 获取数组第一个元素
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type F = First<[3, 2, 1]>; // 3
// 3. 获取元组长度
type Length<T extends readonly any[]> = T['length'];
type L = Length<['a', 'b', 'c']>; // 3
// 4. 实现 Concat
type Concat<T extends any[], U extends any[]> = [...T, ...U];
type Merged = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]
// 5. 实现 Includes
type Includes<T extends readonly any[], U> =
T extends [infer First, ...infer Rest]
? Equal<First, U> extends true
? true
: Includes<Rest, U>
: false;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type Has = Includes<[1, 2, 3], 2>; // true真实案例:类型安全的表单校验器
设想一个表单库,希望根据字段配置自动推导出表单值的类型,避免手写重复类型。
type FieldConfig =
| { type: 'text' }
| { type: 'number' }
| { type: 'checkbox' };
// 把配置里的 type 字符串映射成真实的值类型
type InferFieldType<T extends FieldConfig> =
T['type'] extends 'text' ? string :
T['type'] extends 'number' ? number :
T['type'] extends 'checkbox' ? boolean :
never;
// 从整份表单 schema 推导出表单值对象类型
type FormValues<T extends Record<string, FieldConfig>> = {
[K in keyof T]: InferFieldType<T[K]>;
};
const schema = {
username: { type: 'text' },
age: { type: 'number' },
subscribe: { type: 'checkbox' },
} as const;
type Values = FormValues<typeof schema>;
// {
// username: string;
// age: number;
// subscribe: boolean;
// }只写一份 schema,表单值类型、校验、提交全部自动获得类型保护——这正是 tRPC、Zod 等库背后的核心思想。
性能与复杂度对比
| 特性 | 编译开销 | 可读性 | 典型用途 |
| --- | --- | --- | --- |
| 条件类型 | 低 | 中 | 类型分支、提取 |
| 映射类型 | 低 | 高 | 批量转换属性 |
| 模板字面量 | 中 | 中 | 字符串类型构造 |
| 递归类型 | 高(深层易爆栈) | 低 | 深度嵌套结构 |
TypeScript 对递归有约 50 层的默认深度限制,超过会报"Type instantiation is excessively deep"。生产代码里应尽量控制递归深度。
常见坑
最佳实践
内置工具类型全景
TypeScript 内置了一批工具类型,绝大多数日常需求不用自己造轮子。理解它们的实现原理,本身就是类型体操的最佳入门教材。
| 工具类型 | 作用 | 等价实现 |
| --- | --- | --- |
| Partial T | 所有属性变可选 | 映射类型 + ? 修饰符 |
| Required T | 所有属性变必选 | 映射类型 + -? 修饰符 |
| Readonly T | 所有属性变只读 | 映射类型 + readonly |
| Pick T K | 挑选部分属性 | 映射遍历 K |
| Omit T K | 排除部分属性 | Pick + Exclude keyof |
| Record K V | 构造键值类型 | 映射类型 in K |
| Exclude T U | 从联合中排除 | 分布式条件类型 |
| Extract T U | 从联合中提取 | 分布式条件类型 |
| NonNullable T | 去除 null 和 undefined | Exclude null undefined |
| ReturnType T | 函数返回值类型 | infer R |
| Parameters T | 函数参数元组类型 | infer P |
| Awaited T | 递归解包 Promise | 条件 + infer 递归 |
| InstanceType T | 构造函数实例类型 | infer R |
// Omit 的完整实现
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
interface Product {
id: number;
name: string;
price: number;
internalCost: number;
}
type PublicProduct = MyOmit<Product, 'internalCost'>;
// { id: number; name: string; price: number }
// Record 的实现
type MyRecord<K extends keyof any, V> = { [P in K]: V };
type Flags = MyRecord<'a' | 'b' | 'c', boolean>;
// { a: boolean; b: boolean; c: boolean }类型守卫与类型收窄
类型收窄(narrowing)让编译器在运行时判断的分支里自动推导出更精确的类型,是把类型系统和运行时逻辑连接起来的桥梁。
// 1. typeof 守卫
function pad(value: string | number) {
if (typeof value === 'number') {
return value.toFixed(2); // 此处 value 被收窄为 number
}
return value.trim(); // 此处 value 被收窄为 string
}
// 2. in 操作符守卫
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(animal: Cat | Dog) {
if ('meow' in animal) animal.meow();
else animal.bark();
}
// 3. 自定义类型守卫(is 谓词)
interface Fish { swim: () => void }
interface Bird { fly: () => void }
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) pet.swim();
else pet.fly();
}
// 4. 断言函数(asserts)
function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === undefined || val === null) {
throw new Error('值不应为空');
}
}
function process(input?: string) {
assertIsDefined(input);
return input.toUpperCase(); // input 已收窄为 string
}可辨识联合与穷尽检查
可辨识联合(discriminated union)是 TypeScript 建模状态的黄金模式:用一个共同的字面量字段(tag)区分成员,配合 `never` 做穷尽检查,新增状态忘记处理时编译期直接报错。
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function render(state: State): string {
switch (state.status) {
case 'idle': return '空闲';
case 'loading': return '加载中...';
case 'success': return `共 ${state.data.length} 条`; // data 类型可用
case 'error': return `错误:${state.message}`; // message 类型可用
default:
// 穷尽检查:若将来新增了 status 却没在此处处理,此行会编译报错
const exhaustive: never = state;
return exhaustive;
}
}这一模式广泛用于 Redux reducer、状态机、AST 处理等场景。据经验,把散落的布尔标志位(isLoading、isError、hasData)重构成可辨识联合后,"不可能的状态"(比如同时 loading 又 error)在类型层面就被消灭了。
泛型约束与默认值进阶
// 用 extends 约束泛型,keyof 保证键存在
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Alice' };
getProp(user, 'name'); // string,getProp(user, 'xxx') 会报错
// 泛型默认值
interface ApiResponse<T = unknown> {
code: number;
data: T;
}
const r: ApiResponse = { code: 0, data: 'anything' };
// 用 const 类型参数保留字面量类型(TS 5.0+)
function tuple<const T extends readonly unknown[]>(...args: T): T {
return args;
}
const t = tuple('a', 1, true); // 类型为 readonly ['a', 1, true],而非 (string|number|boolean)[]实战:类型安全的事件系统
用映射类型 + 泛型约束实现一个完全类型安全的事件总线,事件名、payload 类型全程有提示和校验。
// 定义事件到 payload 的映射
interface EventMap {
login: { userId: number; time: number };
logout: { userId: number };
message: { from: string; text: string };
}
class TypedEmitter<T extends Record<string, any>> {
private handlers: { [K in keyof T]?: Array<(payload: T[K]) => void> } = {};
on<K extends keyof T>(event: K, handler: (payload: T[K]) => void) {
(this.handlers[event] ||= []).push(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]) {
this.handlers[event]?.forEach(h => h(payload));
}
}
const bus = new TypedEmitter<EventMap>();
bus.on('login', p => console.log(p.userId, p.time)); // p 类型精确
bus.emit('login', { userId: 1, time: Date.now() }); // payload 校验
// bus.emit('login', { userId: 1 }); // 报错:缺少 time
// bus.on('unknown', () => {}); // 报错:事件名不存在协变、逆变与函数类型兼容
理解型变(variance)能帮你搞懂"为什么这个函数类型不能赋值给那个"。TypeScript 中:函数参数是逆变的(bivariant 在非严格模式下),返回值是协变的。
class Animal {}
class Dog extends Animal { bark() {} }
// 返回值协变:返回更具体的类型是安全的
type GetAnimal = () => Animal;
const getDog: GetAnimal = () => new Dog(); // OK,Dog 是 Animal
// 参数逆变:接受更宽泛的参数是安全的(strictFunctionTypes 下)
type HandleDog = (d: Dog) => void;
const handleAnimal: HandleDog = (a: Animal) => {}; // OK开启 `strictFunctionTypes` 后,函数参数按严格逆变检查,能捕获更多不安全的赋值。
总结
| 高级特性 | 一句话记忆 | 关键词 |
| --- | --- | --- |
| 条件类型 | 类型层面的三元运算符 | extends ? : |
| infer | 在条件类型里捕获类型 | 提取、解包 |
| 映射类型 | 遍历并批量转换属性 | in keyof、修饰符 |
| 键重映射 | 用 as 重命名键 | 模板字面量、never 过滤 |
| 模板字面量类型 | 类型层面拼接/解析字符串 | 笛卡尔积、路径参数 |
| 递归类型 | 类型引用自身处理嵌套 | Deep*、JSON |
一句话:类型体操的终极目标不是复杂,而是让复杂的约束"自动"成立,从而写更少、更安全的代码。