TypeScript中的高级类型:条件类型与映射类型
条件类型(Conditional Types)
基础概念
在 TypeScript 中,条件类型允许我们根据类型关系来选择不同的类型。它的语法借鉴了 JavaScript 中的三元表达式 condition ? trueValue : falseValue
。在类型层面,条件类型的基本语法为 T extends U ? X : Y
,其中 T
和 U
是类型,X
和 Y
也是类型。如果 T
能够赋值给 U
(即 T
是 U
的子类型),那么条件类型解析为 X
,否则解析为 Y
。
比如,我们定义一个简单的条件类型:
type IsString<T> = T extends string ? true : false;
type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false
在上述代码中,IsString
是一个条件类型,它接收一个类型参数 T
。如果 T
是 string
类型,那么 IsString<T>
就是 true
,否则就是 false
。
条件类型的作用
- 类型过滤
条件类型可以用于从联合类型中过滤出符合特定条件的类型。例如,我们有一个联合类型
string | number | boolean
,我们只想获取其中的string
类型。
type ExtractString<T> = T extends string ? T : never;
type MyUnion = string | number | boolean;
type Filtered = ExtractString<MyUnion>; // string
这里的 ExtractString
条件类型接收一个联合类型 T
,对于 T
中的每个类型,如果它是 string
,就保留该类型,否则用 never
替换。never
类型表示没有值的类型,所以最终结果只保留了 string
类型。
- 类型转换
可以根据条件将一种类型转换为另一种类型。假设我们有一个类型,表示可能是
string
或者number
,我们想把string
类型转换为它的长度类型,number
类型保持不变。
type Transform<T> = T extends string ? T['length'] : T;
type StringResult = Transform<string>; // number
type NumberResult = Transform<number>; // number
在 Transform
条件类型中,当 T
是 string
时,返回 string
的 length
属性的类型(即 number
),当 T
是 number
时,直接返回 number
。
分布式条件类型
当条件类型作用于联合类型时,会发生分布式行为。例如:
type IsString<T> = T extends string ? true : false;
type UnionCheck = IsString<string | number>;
// true | false
这里 IsString<string | number>
会被分布式处理为 IsString<string> | IsString<number>
,即 true | false
。
如果我们想避免这种分布式行为,可以在 extends
关键字前加上方括号。
type NonDistributiveIsString<T> = [T] extends [string] ? true : false;
type NonDistributiveUnionCheck = NonDistributiveIsString<string | number>;
// false
此时,[T] extends [string]
会把联合类型 string | number
当作一个整体来判断,由于 string | number
不能赋值给 string
,所以结果为 false
。
条件类型中的 infer 关键字
infer
关键字用于在条件类型中声明一个类型变量,这个变量会根据条件自动推断。例如,我们有一个函数类型,我们想获取它的返回值类型。
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function add(a: number, b: number): number {
return a + b;
}
type AddReturnType = GetReturnType<typeof add>; // number
在 GetReturnType
条件类型中,T extends (...args: any[]) => infer R
表示如果 T
是一个函数类型,那么推断出它的返回值类型为 R
。然后根据条件返回 R
或者 never
。
再比如,我们想从数组类型中获取元素类型。
type GetElementType<T> = T extends Array<infer E> ? E : never;
type NumberArray = number[];
type ElementType = GetElementType<NumberArray>; // number
这里 T extends Array<infer E>
表示如果 T
是数组类型,推断出数组元素类型为 E
,然后返回 E
或者 never
。
映射类型(Mapped Types)
基础概念
映射类型允许我们基于一个已有类型创建一个新类型,通过对已有类型的每个属性进行变换。其基本语法是在 {}
内使用 [P in K]
语法,其中 K
是一个联合类型,通常是已有类型的属性键,P
是 K
中的每个属性键。我们可以对每个 P
进行类型变换来创建新类型。
例如,我们有一个简单的类型 User
:
type User = {
name: string;
age: number;
};
我们可以创建一个新类型,将 User
类型的所有属性变为只读。
type ReadonlyUser = {
readonly [P in keyof User]: User[P];
};
在 ReadonlyUser
类型中,keyof User
获取 User
类型的所有属性键,即 'name' | 'age'
。然后对于每个属性键 P
,我们创建一个新的属性,这个属性是只读的,并且类型和 User
中对应的属性类型相同。
映射类型的用途
- 属性修饰 除了将属性变为只读,我们还可以将属性变为可选。
type OptionalUser = {
[P in keyof User]?: User[P];
};
这里在属性定义前加上 ?
,就将 User
类型的所有属性变为了可选。
- 属性类型变换 假设我们有一个类型,属性值都是字符串,我们想把它们都变为数字类型。
type StringToNumber<T> = {
[P in keyof T]: number;
};
type StringProps = {
a: string;
b: string;
};
type NumberProps = StringToNumber<StringProps>;
// { a: number; b: number; }
在 StringToNumber
映射类型中,对于 T
的每个属性键 P
,我们将其类型变为 number
。
映射修饰符
在映射类型中,有一些修饰符可以帮助我们更方便地对属性进行操作。
-
readonly 和 ? 我们前面已经介绍过
readonly
用于将属性变为只读,?
用于将属性变为可选。 -
+ 和 -
+
和-
可以用于添加或移除属性修饰符。例如,我们想移除ReadonlyUser
类型中的只读修饰符。
type MutableUser = {
-readonly [P in keyof ReadonlyUser]: ReadonlyUser[P];
};
这里 -readonly
移除了 ReadonlyUser
类型中属性的只读修饰符。同样,如果我们想给 OptionalUser
类型的属性添加可选修饰符(虽然它本身已经是可选的,这里只是示例),可以使用 +?
。
type MoreOptionalUser = {
+? [P in keyof OptionalUser]: OptionalUser[P];
};
映射类型与条件类型的结合
我们可以将映射类型和条件类型结合使用,实现更强大的类型变换。例如,我们有一个类型,其中部分属性是字符串,部分是数字,我们想把字符串属性变为大写形式的字符串,数字属性保持不变。
type TransformProps<T> = {
[P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P];
};
type MixedProps = {
name: string;
age: number;
};
type TransformedProps = TransformProps<MixedProps>;
// { name: 'STRING'; age: number; }
在 TransformProps
映射类型中,对于 T
的每个属性键 P
,我们使用条件类型判断 T[P]
是否为 string
类型。如果是,就将其转换为大写形式的字符串,否则保持不变。
条件类型与映射类型的实际应用场景
在函数重载中的应用
在 TypeScript 中,函数重载是一种非常有用的特性,条件类型和映射类型可以帮助我们更好地实现函数重载。例如,我们有一个函数,它可以接受不同类型的参数并返回不同类型的结果。
type ReturnTypeByArg<T> = T extends string ? number : T extends number ? string : boolean;
function transform<T>(arg: T): ReturnTypeByArg<T> {
if (typeof arg ==='string') {
return arg.length as any;
} else if (typeof arg === 'number') {
return arg.toString() as any;
}
return true as any;
}
let result1 = transform('hello'); // number
let result2 = transform(10); // string
let result3 = transform(true); // boolean
这里通过 ReturnTypeByArg
条件类型,根据传入参数的类型确定返回值的类型,使得函数在不同参数类型下有不同的返回类型,模拟了函数重载的效果。
在 React 组件类型定义中的应用
在 React 开发中,我们经常需要定义组件的 props 类型。条件类型和映射类型可以帮助我们更灵活地定义 props。例如,我们有一个通用的表单组件,它可以根据不同的输入类型(文本、数字等)显示不同的输入框样式。
type InputType = 'text' | 'number';
type TextInputProps = {
type: 'text';
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
type NumberInputProps = {
type: 'number';
value: number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
type InputProps<T extends InputType> = T extends 'text' ? TextInputProps : NumberInputProps;
function Input<T extends InputType>(props: InputProps<T>) {
return <input type={props.type} value={props.value} onChange={props.onChange} />;
}
let textInput = <Input type="text" value="hello" onChange={() => {}} />;
let numberInput = <Input type="number" value={10} onChange={() => {}} />;
这里通过条件类型 InputProps
根据 type
属性的值确定具体的 props 类型,使得 Input
组件可以适应不同类型的输入。
再比如,我们想对 React 组件的 props 进行一些处理,比如将所有 props 变为只读。
import React from'react';
type Props = {
name: string;
age: number;
};
type ReadonlyProps = {
readonly [P in keyof Props]: Props[P];
};
const MyComponent: React.FC<ReadonlyProps> = (props) => {
return (
<div>
<p>Name: {props.name}</p>
<p>Age: {props.age}</p>
</div>
);
};
let component = <MyComponent name="John" age={30} />;
这里使用映射类型将 Props
类型的所有属性变为只读,确保在 MyComponent
组件中不会意外修改 props。
在库开发中的应用
在开发 TypeScript 库时,条件类型和映射类型可以帮助我们提供更灵活的类型定义。例如,我们开发一个数据请求库,它可以根据请求的类型返回不同格式的数据。
type RequestType = 'json' | 'text';
type JsonResponse<T> = {
data: T;
status: number;
};
type TextResponse = {
text: string;
status: number;
};
type ResponseType<T extends RequestType, U> = T extends 'json' ? JsonResponse<U> : TextResponse;
function makeRequest<T extends RequestType, U>(type: T, data: U): ResponseType<T, U> {
if (type === 'json') {
return { data, status: 200 } as any;
} else {
return { text: JSON.stringify(data), status: 200 } as any;
}
}
let jsonResponse = makeRequest('json', { message: 'Hello' });
// { data: { message: 'Hello' }; status: 200; }
let textResponse = makeRequest('text', { message: 'Hello' });
// { text: '{"message":"Hello"}'; status: 200; }
这里通过条件类型 ResponseType
根据请求类型 T
确定返回数据的类型,使得库在不同请求类型下有不同的返回类型定义。
复杂条件类型与映射类型的深入理解
嵌套条件类型
条件类型可以嵌套使用,以实现更复杂的类型判断。例如,我们有一个类型,表示可能是数组或者普通类型。如果是数组,我们想获取数组元素类型,如果数组元素类型又是数组,我们继续获取内层数组元素类型,直到不是数组类型为止。
type DeepElementType<T> = T extends Array<infer E> ? DeepElementType<E> : T;
type NestedArray = number[][][];
type DeepElement = DeepElementType<NestedArray>; // number
在 DeepElementType
条件类型中,首先判断 T
是否为数组类型,如果是,就递归调用 DeepElementType
处理数组元素类型 E
,直到 T
不是数组类型,返回最终的类型。
映射类型的递归
映射类型也可以实现递归。例如,我们有一个类型,表示嵌套的对象结构,我们想将所有属性变为只读。
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
type NestedObject = {
a: {
b: string;
};
c: number;
};
type DeepReadonlyObject = DeepReadonly<NestedObject>;
// { readonly a: { readonly b: string; }; readonly c: number; }
在 DeepReadonly
映射类型中,对于 T
的每个属性 P
,如果 T[P]
是对象类型,就递归调用 DeepReadonly
将其变为只读,否则直接保持属性不变并变为只读。
条件类型与映射类型的类型分发优化
在使用条件类型和映射类型时,特别是处理联合类型时,可能会遇到性能问题,因为会发生类型分发。我们可以通过一些技巧来优化。例如,使用 Exclude
和 Extract
等工具类型来减少不必要的类型分发。
type Union = string | number | boolean;
type StringOrNumber = Exclude<Union, boolean>;
type FilteredUnion = StringOrNumber extends string | number ? StringOrNumber : never;
// string | number
这里先使用 Exclude
从 Union
联合类型中排除 boolean
类型,得到 StringOrNumber
,然后再进行条件类型判断,这样可以减少类型分发的范围,提高性能。
常见错误与注意事项
条件类型中的类型判断错误
在使用条件类型时,要注意类型判断的准确性。例如,在判断两个类型是否相等时,要使用正确的方式。
type IsEqual<T, U> = (<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2) ? true : false;
type Check = IsEqual<string, string>; // true
type WrongCheck = IsEqual<string, number>; // false
这里通过函数类型的比较来判断两个类型是否相等,不能简单地使用 T === U
来判断类型相等,因为在 TypeScript 类型层面 ===
操作符并不适用。
映射类型中的属性键冲突
在使用映射类型时,如果不小心可能会导致属性键冲突。例如,在创建新类型时,属性键重复。
type BadMapping = {
[P in 'a' | 'b' | 'a']: number;
// 这里 'a' 重复,会导致错误
};
要确保在 [P in K]
中,K
中的属性键是唯一的,避免属性键冲突。
条件类型与映射类型的性能问题
如前面提到的,条件类型和映射类型在处理联合类型时会发生类型分发,这可能会导致性能问题。特别是在处理复杂的联合类型和递归的条件类型与映射类型时,要注意性能优化。尽量减少不必要的类型分发,使用工具类型来提前过滤和处理类型,以提高类型检查的效率。
通过深入理解和熟练运用 TypeScript 中的条件类型与映射类型,我们可以在前端开发中实现更灵活、强大和类型安全的代码。无论是在 React 组件开发、库开发还是其他前端项目中,它们都能帮助我们更好地处理类型关系,提高代码的质量和可维护性。