为什么需要条件类型与映射类型?
在日常开发中,我们经常会遇到这样的场景:需要根据一个类型动态生成另一个类型,或者对现有类型的属性进行批量修改。比如,从一个大对象类型中提取出所有函数类型的属性,或者将某个类型的所有属性变为只读。这些需求如果手动编写类型,不仅繁琐而且容易出错。TypeScript 的条件类型(Conditional Types)和映射类型(Mapped Types)正是为了解决这类问题而生的。
条件类型允许我们根据类型关系进行分支判断,类似于 JavaScript 中的三元表达式;映射类型则允许我们基于旧类型创建新类型,通过遍历属性键来生成新的属性类型。两者结合,可以构建出非常强大的类型工具。
本文将从基础概念出发,通过实现几个实用的工具类型,带你深入理解条件类型与映射类型的应用,并分享一些最佳实践和常见陷阱。
环境准备
确保你的 TypeScript 版本在 4.1 以上(推荐 4.5+),因为我们会用到模板字面量类型和递归条件类型。创建一个新的项目目录,初始化 npm,并安装 TypeScript:
mkdir ts-advanced-types
cd ts-advanced-types
npm init -y
npm install typescript --save-dev
npx tsc --init
在 tsconfig.json 中启用严格模式:
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"module": "CommonJS"
}
}
1. 条件类型基础
条件类型的语法如下:
type A = T extends U ? X : Y;
如果类型 T 可以赋值给类型 U,则结果类型为 X,否则为 Y。
例如,我们可以实现一个简单的 IsString 类型:
type IsString<T> = T extends string ? true : false;
type T1 = IsString<'hello'>; // true
type T2 = IsString<42>; // false
条件类型还支持分布式条件类型:当条件类型作用于联合类型时,它会自动分发到每个成员。例如:
type ToArray<T> = T extends any ? T[] : never;
type T3 = ToArray<string | number>; // string[] | number[]
注意,分布式行为只在条件类型直接作用于裸类型参数时发生。如果被包裹在元组或对象中,则不会分发。
2. 映射类型基础
映射类型通过 in 关键字遍历属性键来创建新类型。语法如下:
type Mapped<T> = {
[P in keyof T]: T[P];
};
我们可以添加修饰符,如 readonly 或 ?,也可以通过 + 和 - 来添加或移除修饰符。
例如,实现一个 MyPartial:
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
3. 实战:实现 PickByValue
PickByValue 用于从对象类型中挑选出值类型符合特定条件的属性。这是一个结合条件类型和映射类型的经典例子。
type PickByValue<T, ValueType> = Pick<
T,
{ [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>;
解释:
{ [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }创建一个映射类型,对于每个属性,如果其值类型可赋值给ValueType,则保留键名,否则为never。[keyof T]索引访问得到所有键的联合,但包含never,而never在联合中会被忽略。- 最后用
Pick从T中挑选出这些键。
使用示例:
interface Person {
name: string;
age: number;
isActive: boolean;
greet: () => void;
}
type StringKeys = PickByValue<Person, string>; // { name: string }
type FunctionKeys = PickByValue<Person, Function>; // { greet: () => void }
💡 注意:-? 修饰符用于移除可选性,确保在条件判断中不会因为 undefined 而干扰。
4. 实战:实现 DeepReadonly
DeepReadonly 递归地将对象的所有属性变为只读,包括嵌套对象。这需要结合映射类型和条件类型。
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
但是,这个简单版本对于数组和函数会有问题:函数也是对象,会被递归处理,导致类型错误。我们需要排除函数和数组,或者对数组单独处理。
改进版:
type DeepReadonly<T> = T extends Function
? T
: T extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
使用示例:
interface User {
name: string;
address: {
city: string;
zip: number;
};
hobbies: string[];
}
type ReadonlyUser = DeepReadonly<User>;
// 等价于:
// {
// readonly name: string;
// readonly address: {
// readonly city: string;
// readonly zip: number;
// };
// readonly hobbies: ReadonlyArray<string>;
// }
💡 注意:递归条件类型在处理深度嵌套或循环引用时可能导致性能问题或无限递归。对于循环引用,TypeScript 会报错“Type instantiation is excessively deep”。在实际项目中,应避免过度递归,或使用接口来打破循环。
5. 实战:实现 RequireAtLeastOne
有时我们希望对象至少有一个属性是必需的,其他属性可选。这可以通过映射类型和条件类型实现。
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
T,
Exclude<keyof T, Keys>
> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
使用示例:
interface Options {
a: string;
b: number;
c: boolean;
}
type AtLeastOne = RequireAtLeastOne<Options>;
const obj1: AtLeastOne = { a: 'hello' }; // 正确
const obj2: AtLeastOne = { b: 42, c: true }; // 正确
const obj3: AtLeastOne = {}; // 错误:至少需要一个属性
6. 常见陷阱与最佳实践
- 分布式条件类型:当条件类型作用于联合类型时,默认会分发。如果不希望分发,可以用元组包裹:
[T] extends [U] ? X : Y。 never的处理:在映射类型中,never作为键会被忽略,这常用于过滤。- 递归深度限制:TypeScript 对递归类型有深度限制(约 50 层),对于深层嵌套的数据结构,考虑使用迭代方法或放宽类型。
- 性能优化:复杂的条件类型和映射类型可能影响编译速度。尽量使用类型别名缓存中间结果,避免重复计算。
- 测试类型:可以使用
// @ts-expect-error注释来测试类型错误,确保工具类型按预期工作。
总结
条件类型和映射类型是 TypeScript 类型系统的强大工具,掌握它们可以让你编写出更灵活、更安全的类型定义。本文通过实现 PickByValue、DeepReadonly 和 RequireAtLeastOne 等实用工具类型,展示了它们的实际应用。建议你在项目中尝试封装自己的工具类型,并编写类型测试来保证正确性。
下一步,你可以探索模板字面量类型、infer 关键字的高级用法,以及如何发布自己的类型工具库到 npm。