TypeScript 类型体操实践
TypeScript 的类型系统异常强大,远超出简单的类型标注功能。通过类型编程(Type Programming),我们可以在编译时实现复杂的类型推导和转换,这种实践通常被戏称为"类型体操"。本文将介绍一系列实用的 TypeScript 类型挑战,帮助你掌握类型编程的精髓。
什么是类型体操?
类型体操是指利用 TypeScript 的类型系统进行复杂类型操作和转换的实践。这些操作往往看起来很像"编程",只不过它们完全在类型层面工作,由编译器在编译时计算。
特点:
- 完全在编译时执行,不产生运行时开销
- 主要通过条件类型、映射类型、递归类型等实现
- 可以大幅增强代码的类型安全性
- 具有解谜游戏般的乐趣
基础挑战
让我们从一些基础的类型挑战开始:
1. 实现 Pick<T, K>
从对象类型中选取指定属性集合:
// 挑战:实现一个类型 MyPick,类似于内置的 Pick 类型
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// 用例
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>;
// 结果: { title: string; completed: boolean; }
解析:
K extends keyof T
确保我们只能选择 T 中存在的键[P in K]
遍历 K 中的每个属性T[P]
保留原对象中该属性的类型
2. 实现 Readonly<T>
将对象的所有属性设为只读:
// 挑战:实现一个类型 MyReadonly,使所有属性只读
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
// 用例
interface User {
name: string;
age: number;
}
const user: MyReadonly<User> = {
name: 'John',
age: 30
};
// 错误:无法分配给"name",因为它是只读属性
// user.name = 'Tom';
解析:
keyof T
获取 T 的所有属性键[P in keyof T]
遍历所有键readonly
修饰符将每个属性标记为只读
中级挑战
接下来,让我们尝试一些更复杂的挑战:
3. 实现 DeepReadonly<T>
递归地将嵌套对象的所有属性设为只读:
// 挑战:实现深度只读类型转换
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object
? T[P] extends Function
? T[P]
: DeepReadonly<T[P]>
: T[P]
}
// 用例
interface NestedObject {
name: string;
settings: {
theme: string;
notification: {
email: boolean;
sms: boolean;
}
}
}
const obj: DeepReadonly<NestedObject> = {
name: 'App',
settings: {
theme: 'dark',
notification: {
email: true,
sms: false
}
}
};
// 以下所有赋值操作都会导致编译错误
// obj.name = 'New App';
// obj.settings.theme = 'light';
// obj.settings.notification.email = false;
解析:
- 递归地处理嵌套对象
- 使用条件类型
T[P] extends object ? ... : ...
检查是否为对象 - 对函数类型做特殊处理,避免过度递归
- 递归应用
DeepReadonly
到嵌套对象属性
4. 实现 Flatten<T>
将嵌套数组展平:
// 挑战:实现数组扁平化
type Flatten<T extends any[]> =
T extends [infer First, ...infer Rest]
? First extends any[]
? [...Flatten<First>, ...Flatten<Rest>]
: [First, ...Flatten<Rest>]
: [];
// 用例
type NestedArray = [1, [2, 3], [4, [5, 6]]];
type FlatArray = Flatten<NestedArray>;
// 结果: [1, 2, 3, 4, 5, 6]
解析:
- 使用
infer
推断数组的第一个元素First
和剩余元素Rest
- 如果
First
是数组,递归地展平它 - 否则,保留
First
作为单个元素 - 最后,递归地处理剩余元素
Rest
5. 实现 TupleToObject<T>
将元组转换为对象:
// 挑战:将元组转换为对象,其中元素值作为键和值
type TupleToObject<T extends readonly (string | number | symbol)[]> = {
[P in T[number]]: P
}
// 用例
const tuple = ['tesla', 'model3', 'model X', 'model Y'] as const;
type Result = TupleToObject<typeof tuple>;
// 结果: { tesla: 'tesla'; 'model3': 'model3'; 'model X': 'model X'; 'model Y': 'model Y' }
解析:
T[number]
获取元组中的所有元素类型的联合[P in T[number]]: P
将每个元素作为键和值创建对象类型
高级挑战
现在,让我们挑战一些真正高难度的类型体操:
6. 实现 Promise.all
的类型
为 Promise.all
函数提供正确的类型定义:
// 挑战:实现 Promise.all 的类型
declare function PromiseAll<T extends any[]>(
values: readonly [...T]
): Promise<{
[K in keyof T]: T[K] extends Promise<infer R> ? R : T[K]
}>;
// 用例
const promise1 = Promise.resolve(1);
const promise2 = Promise.resolve('2');
const promise3 = Promise.resolve(true);
const result = PromiseAll([promise1, promise2, promise3]);
// 类型为: Promise<[number, string, boolean]>
解析:
- 使用泛型
T extends any[]
捕获输入数组的类型 - 使用映射类型解包每个 Promise
- 对于每个位置
K
,检查T[K]
是否为 Promise - 如果是 Promise,提取其解析值类型,否则保持原类型
7. 实现 Chainable
类型
实现链式调用的类型推导:
// 挑战:实现链式调用的类型
type Chainable<T = {}> = {
option<K extends string, V>(
key: K extends keyof T ? never : K,
value: V
): Chainable<T & { [P in K]: V }>;
get(): T;
};
// 用例
const config = {};
const result = (config as Chainable)
.option('name', 'typescript')
.option('version', 4.2)
.option('features', ['type-safety', 'tooling'])
.get();
// 类型为: { name: string; version: number; features: string[] }
解析:
- 使用泛型参数
T
跟踪已添加的属性 option
方法接受键K
和值V
,并返回包含新属性的Chainable
- 使用
K extends keyof T ? never : K
防止重复添加相同的键 get
方法返回最终结果对象
8. 实现 ParseQueryString
将查询字符串解析为对象类型:
// 挑战:解析查询字符串
type ParseQueryString<S extends string> =
S extends `${infer Param}&${infer Rest}`
? MergeParams<
ParseParam<Param>,
ParseQueryString<Rest>
>
: ParseParam<S>;
type ParseParam<P extends string> =
P extends `${infer Key}=${infer Value}`
? { [K in Key]: Value }
: {};
type MergeParams<
P1 extends Record<string, any>,
P2 extends Record<string, any>
> = {
[K in keyof P1 | keyof P2]:
K extends keyof P1
? K extends keyof P2
? P1[K] | P2[K]
: P1[K]
: K extends keyof P2
? P2[K]
: never;
};
// 用例
type Query = ParseQueryString<'foo=bar&baz=qux&foo=quux'>;
// 结果: { foo: "bar" | "quux"; baz: "qux" }
解析:
- 使用模板字面量类型和
infer
解析查询字符串 - 递归地处理每个参数对
- 处理同名参数,将其值合并为联合类型
- 最终构造出完整的对象类型
极限挑战
最后,让我们尝试一些极限挑战,展示 TypeScript 类型系统的强大能力:
9. 实现 Currying
类型
为函数柯里化提供类型支持:
// 挑战:实现函数柯里化的类型
type Currying<F> =
F extends (...args: infer Args) => infer Return
? Args extends [infer First, ...infer Rest]
? Rest['length'] extends 0
? F
: (arg: First) => Currying<(...args: Rest) => Return>
: F
: never;
// 用例
const add = (a: number, b: number, c: number) => a + b + c;
const curriedAdd: Currying<typeof add> =
a => b => c => a + b + c;
// 类型安全
const result1 = curriedAdd(1)(2)(3); // 正确: number
// const result2 = curriedAdd(1)(2); // 错误: 缺少最后一个参数
// const result3 = curriedAdd(1, 2); // 错误: 应当逐个应用参数
解析:
- 解构函数类型,提取参数类型
Args
和返回类型Return
- 逐步解构参数,将每个参数转换为单独的函数调用
- 递归处理,直到所有参数都被消费
10. 实现类型版的 FizzBuzz
在类型级别实现著名的 FizzBuzz 问题:
// 挑战:类型级别的 FizzBuzz
type Numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
type FizzBuzz<
T extends number[],
Result extends any[] = []
> = T extends [infer First extends number, ...infer Rest extends number[]]
? FizzBuzz<
Rest,
[
...Result,
First extends number
? DivisibleBy<First, 15> extends true
? 'FizzBuzz'
: DivisibleBy<First, 3> extends true
? 'Fizz'
: DivisibleBy<First, 5> extends true
? 'Buzz'
: First
: never
]
>
: Result;
type DivisibleBy<N extends number, M extends number> =
N extends 0
? true
: N extends M
? true
: N extends 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14
? false
: DivisibleBy<Subtract<N, M>, M>;
type Subtract<A extends number, B extends number> =
TupleOfLength<A> extends [...TupleOfLength<B>, ...infer Rest]
? Rest['length']
: 0;
type TupleOfLength<N extends number, T extends any[] = []> =
T['length'] extends N ? T : TupleOfLength<N, [...T, any]>;
// 用例
type Result = FizzBuzz<Numbers>;
// 结果: [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]
解析:
- 在类型系统中实现 FizzBuzz 问题
- 使用递归类型处理数组中的每个数字
- 实现类型级别的算术运算(减法和整除判断)
- 通过元组长度进行数值运算
实用工具类型
类型体操不仅仅是智力游戏,它能解决实际开发中的类型问题。以下是一些实用的工具类型:
1. 提取嵌套对象的路径类型
// 获取对象的所有可能路径
type PathsOf<T, D extends string = ""> = T extends object
? {
[K in keyof T]: K extends string
? PathsOf<T[K], D extends "" ? K : `${D}.${K}`> | (D extends "" ? K : `${D}.${K}`)
: never;
}[keyof T]
: D;
// 用例
interface User {
name: string;
address: {
street: string;
city: string;
geo: {
lat: number;
lng: number;
};
};
orders: {
id: number;
items: {
productId: number;
quantity: number;
}[];
}[];
}
type UserPaths = PathsOf<User>;
// 结果包含: "name", "address", "address.street", "address.city", "address.geo",
// "address.geo.lat", "address.geo.lng", "orders", "orders.id", "orders.items"...
2. 深度可选类型
// 将所有属性(包括嵌套属性)设为可选
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
// 用例
interface Settings {
theme: {
light: {
background: string;
text: string;
};
dark: {
background: string;
text: string;
};
};
notifications: {
email: boolean;
push: boolean;
frequency: 'daily' | 'weekly' | 'monthly';
};
}
// 用于表示部分设置更新
function updateSettings(settings: DeepPartial<Settings>) {
// 实现省略...
}
// 可以只提供需要更新的部分属性
updateSettings({
theme: {
dark: {
background: '#000'
}
}
});
3. 联合类型转交叉类型
// 将联合类型转换为交叉类型
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
// 用例
type Union = { a: string } | { b: number } | { c: boolean };
type Intersection = UnionToIntersection<Union>;
// 结果: { a: string } & { b: number } & { c: boolean }
// 实际应用:合并多个函数的参数类型
type MergeParameters<Funcs extends ((...args: any) => any)[]> =
UnionToIntersection<Funcs[number] extends (...args: infer Args) => any ? Args[0] : never>;
function compose<F extends ((...args: any) => any)[]>(...funcs: F) {
return (params: MergeParameters<F>) => {
// 实现省略...
};
}
实践中的类型体操
在实际项目中,类型体操可以帮助我们解决很多复杂的类型问题。以下是一些实际场景:
1. API 类型自动生成
从 API 响应推导完整的类型:
// 从API响应推导类型
type InferAPIResponse<T> =
T extends Promise<infer R>
? R extends { data: infer D }
? D
: R
: never;
// 用例
async function fetchUserData(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
type UserData = InferAPIResponse<ReturnType<typeof fetchUserData>>;
2. 类型安全的事件系统
实现类型安全的事件订阅:
// 类型安全的事件系统
type EventMap = {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string; timestamp: number };
'product:view': { productId: string; userId?: string };
'cart:add': { productId: string; quantity: number; userId?: string };
};
// 类型安全的事件发布函数
function emit<E extends keyof EventMap>(event: E, data: EventMap[E]) {
// 实现省略...
}
// 类型安全的事件订阅函数
function on<E extends keyof EventMap>(
event: E,
handler: (data: EventMap[E]) => void
) {
// 实现省略...
}
// 用例 - 类型检查完全工作
on('user:login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emit('product:view', {
productId: 'prod-123',
userId: 'user-456'
});
// 类型错误
// emit('cart:add', { productId: 'prod-123' }); // 缺少 quantity 属性
// emit('user:login', { userId: 'user-123' }); // 缺少 timestamp 属性
3. 状态管理类型推导
为状态管理库提供精确的类型推导:
// Redux-like状态管理类型
type State = {
user: {
id: string | null;
name: string | null;
isLoggedIn: boolean;
};
products: {
items: { id: string; name: string; price: number }[];
loading: boolean;
error: string | null;
};
cart: {
items: { productId: string; quantity: number }[];
total: number;
};
};
type ActionMap = {
'user/login': { id: string; name: string };
'user/logout': undefined;
'products/fetch': undefined;
'products/fetchSuccess': { items: State['products']['items'] };
'products/fetchError': { error: string };
'cart/addItem': { productId: string; quantity: number };
'cart/removeItem': { productId: string };
'cart/clearCart': undefined;
};
// 类型安全的dispatch函数
function dispatch<T extends keyof ActionMap>(
type: T,
payload: ActionMap[T]
): void {
// 实现省略...
}
// 类型安全的reducer
type Reducer<S, A extends keyof ActionMap> =
(state: S, action: { type: A; payload: ActionMap[A] }) => S;
// 用例
const userReducer: Reducer<State['user'], 'user/login' | 'user/logout'> =
(state, action) => {
switch(action.type) {
case 'user/login':
return {
id: action.payload.id,
name: action.payload.name,
isLoggedIn: true
};
case 'user/logout':
return {
id: null,
name: null,
isLoggedIn: false
};
default:
return state;
}
};
// 使用dispatch - 类型安全
dispatch('user/login', { id: 'user-123', name: 'John' });
dispatch('cart/clearCart', undefined);
// 类型错误
// dispatch('user/login', { id: 123, name: 'John' }); // id 类型错误
// dispatch('cart/addItem', { productId: 'prod-123' }); // 缺少 quantity
总结
TypeScript 类型体操是一种强大的技术,它允许我们在编译时进行复杂的类型计算和转换。通过掌握这些技术,我们可以:
- 增强类型安全:在编译时捕获更多潜在错误
- 改善开发体验:提供更精确的自动完成和类型推导
- 实现高级类型抽象:构建可复用的类型工具
- 减少运行时代码:将一些逻辑提升到类型层面
虽然类型体操看起来很复杂,但在实际开发中,它们能够解决许多现实问题,特别是在大型复杂项目中。随着对 TypeScript 类型系统的深入了解,你会发现这些技术不仅仅是智力挑战,更是实用的开发工具。
最后的建议:类型体操虽然有趣且强大,但也要注意平衡。过于复杂的类型可能会导致代码难以理解和维护。始终根据实际需求选择合适的类型复杂度。