MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript中的高级类型:条件类型与映射类型

2023-02-162.5k 阅读

条件类型(Conditional Types)

基础概念

在 TypeScript 中,条件类型允许我们根据类型关系来选择不同的类型。它的语法借鉴了 JavaScript 中的三元表达式 condition ? trueValue : falseValue。在类型层面,条件类型的基本语法为 T extends U ? X : Y,其中 TU 是类型,XY 也是类型。如果 T 能够赋值给 U(即 TU 的子类型),那么条件类型解析为 X,否则解析为 Y

比如,我们定义一个简单的条件类型:

type IsString<T> = T extends string ? true : false;
type StringCheck = IsString<string>; // true
type NumberCheck = IsString<number>; // false

在上述代码中,IsString 是一个条件类型,它接收一个类型参数 T。如果 Tstring 类型,那么 IsString<T> 就是 true,否则就是 false

条件类型的作用

  1. 类型过滤 条件类型可以用于从联合类型中过滤出符合特定条件的类型。例如,我们有一个联合类型 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 类型。

  1. 类型转换 可以根据条件将一种类型转换为另一种类型。假设我们有一个类型,表示可能是 string 或者 number,我们想把 string 类型转换为它的长度类型,number 类型保持不变。
type Transform<T> = T extends string ? T['length'] : T;
type StringResult = Transform<string>; // number
type NumberResult = Transform<number>; // number

Transform 条件类型中,当 Tstring 时,返回 stringlength 属性的类型(即 number),当 Tnumber 时,直接返回 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 是一个联合类型,通常是已有类型的属性键,PK 中的每个属性键。我们可以对每个 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 中对应的属性类型相同。

映射类型的用途

  1. 属性修饰 除了将属性变为只读,我们还可以将属性变为可选。
type OptionalUser = {
    [P in keyof User]?: User[P];
};

这里在属性定义前加上 ?,就将 User 类型的所有属性变为了可选。

  1. 属性类型变换 假设我们有一个类型,属性值都是字符串,我们想把它们都变为数字类型。
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

映射修饰符

在映射类型中,有一些修饰符可以帮助我们更方便地对属性进行操作。

  1. readonly 和 ? 我们前面已经介绍过 readonly 用于将属性变为只读,? 用于将属性变为可选。

  2. + 和 - +- 可以用于添加或移除属性修饰符。例如,我们想移除 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 将其变为只读,否则直接保持属性不变并变为只读。

条件类型与映射类型的类型分发优化

在使用条件类型和映射类型时,特别是处理联合类型时,可能会遇到性能问题,因为会发生类型分发。我们可以通过一些技巧来优化。例如,使用 ExcludeExtract 等工具类型来减少不必要的类型分发。

type Union = string | number | boolean;
type StringOrNumber = Exclude<Union, boolean>;
type FilteredUnion = StringOrNumber extends string | number ? StringOrNumber : never; 
// string | number

这里先使用 ExcludeUnion 联合类型中排除 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 组件开发、库开发还是其他前端项目中,它们都能帮助我们更好地处理类型关系,提高代码的质量和可维护性。