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

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

2023-06-245.7k 阅读

条件类型(Conditional Types)

条件类型基础概念

在 TypeScript 中,条件类型是一种根据条件来选择类型的类型。它的语法形式类似于 JavaScript 中的三元表达式 condition ? trueValue : falseValue,只不过这里是在类型层面进行操作。条件类型的基本语法如下:

T extends U ? X : Y

其中,TU 是类型,extends 用于判断 T 是否可以赋值给 U。如果可以,也就是 TU 的子类型,那么整个条件类型的结果就是 X;否则,结果就是 Y

简单示例:NonNullable 类型

TypeScript 内置了许多有用的条件类型,NonNullable 就是其中之一。它用于从类型中排除 nullundefined。下面是 NonNullable 的实现原理:

type NonNullable<T> = T extends null | undefined ? never : T;

let num: NonNullable<number | null | undefined>;
num = 10; // 正确
// num = null; // 错误,类型“null”不能赋值给类型“number”

在上述代码中,NonNullable<number | null | undefined> 这个条件类型会检查 number | null | undefined 中的每一个类型。对于 number,它不是 null 也不是 undefined,所以保持 number 类型;而对于 nullundefined,会被替换为 never 类型,最终得到的结果就是 number

分布式条件类型

当条件类型的泛型参数是联合类型时,会发生分布式行为。例如:

type IsString<T> = T extends string ? true : false;

type StringOrNumber = string | number;
type StringOrNumberCheck = IsString<StringOrNumber>; 
// 这里 StringOrNumberCheck 实际上是 true | false

在这个例子中,IsString<StringOrNumber> 会被分布式处理,相当于 IsString<string> | IsString<number>,所以结果是 true | false

条件类型中的 infer 关键字

infer 关键字用于在条件类型中推断类型。这在处理函数返回值类型等场景非常有用。例如,我们想要提取函数的返回值类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function add(a: number, b: number): number {
    return a + b;
}

type AddReturnType = ReturnType<typeof add>; 
// AddReturnType 是 number

ReturnType<T> 中,T extends (...args: any[]) => infer R 表示如果 T 是一个函数类型,那么推断出它的返回值类型 R。如果 T 不是函数类型,则返回 any

条件类型的递归使用

条件类型可以递归使用,来实现复杂的类型操作。比如,我们想要实现一个类型,它可以获取对象中所有属性值的类型组成的联合类型:

type ValueOf<T> = T extends any[] ? ValueOf<T[number]> : T extends object ? { [K in keyof T]: ValueOf<T[K]> }[keyof T] : T;

interface User {
    name: string;
    age: number;
    hobbies: string[];
}

type UserValue = ValueOf<User>; 
// UserValue 是 string | number

在上述代码中,ValueOf 首先检查 T 是否是数组类型,如果是,则递归获取数组元素的类型;如果 T 是对象类型,则递归获取对象每个属性值的类型,并通过索引类型 { [K in keyof T]: ValueOf<T[K]> }[keyof T] 得到所有属性值类型的联合类型;如果 T 既不是数组也不是对象,则直接返回 T

映射类型(Mapped Types)

映射类型基础概念

映射类型允许我们基于已有的类型创建新类型,通过对已有类型的每个属性进行变换来生成新类型。其基本语法是在方括号中使用 in 关键字遍历类型的属性。例如:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

interface Point {
    x: number;
    y: number;
}

type ReadonlyPoint = Readonly<Point>; 
// ReadonlyPoint 中的 x 和 y 属性都是只读的

Readonly<T> 中,[P in keyof T] 遍历 T 的所有属性,然后为每个属性添加 readonly 修饰符,这样就创建了一个新类型,其中所有属性都是只读的。

映射修饰符变换

我们不仅可以添加 readonly 修饰符,还可以改变属性的可读写性、可选性等。例如,将所有属性变为可选:

type Partial<T> = {
    [P in keyof T]?: T[P];
};

interface Todo {
    title: string;
    description: string;
}

type PartialTodo = Partial<Todo>; 
// PartialTodo 中的 title 和 description 属性都是可选的

这里通过在属性 P 后面添加 ?,将 Todo 类型中的所有属性都变为可选。

映射类型与条件类型结合

映射类型和条件类型结合可以实现非常强大的功能。比如,我们想要从一个对象类型中过滤出特定类型的属性。假设我们有一个对象类型,其中包含字符串和数字类型的属性,我们只想要字符串类型的属性:

type FilterByType<T, U> = {
    [P in keyof T as T[P] extends U ? P : never]: T[P];
};

interface AllTypes {
    name: string;
    age: number;
    address: string;
}

type StringProperties = FilterByType<AllTypes, string>; 
// StringProperties 只有 name 和 address 属性

FilterByType<T, U> 中,as T[P] extends U ? P : never 这部分使用了条件类型。如果属性 P 的类型 T[P]U 的子类型(这里 Ustring),则保留属性 P,否则将其替换为 never。这样最终得到的 StringProperties 就只包含字符串类型属性。

映射类型的深层次嵌套

映射类型也可以进行深层次的嵌套。例如,我们有一个对象类型,其中的属性值又是对象类型,我们想要将最内层对象的所有属性变为只读:

type DeepReadonly<T> = {
    readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

interface Inner {
    value: number;
}

interface Outer {
    inner: Inner;
}

type DeepReadonlyOuter = DeepReadonly<Outer>; 
// DeepReadonlyOuter 中的 inner 属性的 value 属性是只读的

DeepReadonly<T> 中,首先对 T 的每个属性进行遍历。如果属性值是对象类型,就递归调用 DeepReadonly 将其变为只读;如果不是对象类型,则保持原样。

映射类型的 keyof 和 typeof 结合使用

keyoftypeof 经常与映射类型一起使用,以实现基于现有对象来创建类型。例如:

const user = {
    name: 'John',
    age: 30
};

type UserKeys = keyof typeof user; 
// UserKeys 是 'name' | 'age'

type UserType = {
    [P in UserKeys]: typeof user[P];
}; 
// UserType 是 { name: string; age: number; }

这里首先通过 typeof user 获取 user 对象的类型,然后 keyof typeof user 获取其属性名的联合类型。最后,通过映射类型 [P in UserKeys]: typeof user[P] 创建了与 user 对象结构相同的类型 UserType

映射类型在函数重载中的应用

映射类型在函数重载方面也有应用。假设我们有一个函数,它可以接受不同类型的对象参数,并且根据对象的属性来返回不同的值。我们可以使用映射类型来为这个函数创建重载类型:

function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K];
function getProperty(obj: any, key: any) {
    return obj[key];
}

interface Person {
    name: string;
    age: number;
}

let person: Person = { name: 'Alice', age: 25 };
let name: string = getProperty(person, 'name'); 
// 正确,name 是 string 类型

在上述代码中,getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] 这个重载签名使用了映射类型相关的概念。K extends keyof T 确保 keyobj 对象的有效属性名,然后返回值类型 T[K] 就是 obj 对象中 key 对应的属性值类型。

映射类型与泛型约束的综合运用

当使用映射类型时,结合泛型约束可以进一步增强类型的安全性和灵活性。例如,我们想要创建一个类型,它只接受具有特定属性类型的对象,并且可以对这些属性进行特定的变换。假设我们只接受对象中属性值为数字类型的对象,并且将这些数字属性变为只读:

type OnlyNumberProperties<T extends { [key: string]: number }> = {
    readonly [P in keyof T]: T[P];
};

interface Numbers {
    a: number;
    b: number;
}

type ReadonlyNumbers = OnlyNumberProperties<Numbers>; 
// ReadonlyNumbers 中的 a 和 b 属性都是只读的,并且必须是数字类型

// 下面的代码会报错,因为 c 不是数字类型
// interface NotAllNumbers {
//     a: number;
//     c: string;
// }
// type ReadonlyNotAllNumbers = OnlyNumberProperties<NotAllNumbers>; 

OnlyNumberProperties<T> 中,T extends { [key: string]: number } 对泛型 T 进行了约束,要求 T 是一个对象类型,并且所有属性值必须是数字类型。然后通过映射类型将这些属性变为只读。

条件类型与映射类型的实际应用场景

在 React 组件类型定义中的应用

在 React 开发中,我们经常需要根据组件的属性来定义不同的类型。例如,我们有一个按钮组件,它有一个 disabled 属性来控制按钮是否禁用。我们可以使用条件类型和映射类型来定义更精确的类型:

import React from 'react';

type ButtonProps = {
    children: React.ReactNode;
    disabled?: boolean;
};

type DisabledButtonProps = {
    disabled: true;
};

type EnabledButtonProps = {
    disabled?: false;
};

type ButtonState<T extends ButtonProps> = T extends DisabledButtonProps ? 'disabled' : 'enabled';

function Button<T extends ButtonProps>(props: T) {
    const state: ButtonState<T> = props.disabled ? 'disabled' : 'enabled';
    return <button disabled={props.disabled}>{props.children}</button>;
}

// 使用示例
<Button disabled>Click me (disabled)</Button>;
<Button>Click me (enabled)</Button>;

在上述代码中,ButtonState<T> 使用条件类型根据 ButtonProps 中的 disabled 属性来确定按钮的状态类型。这样在组件内部可以更精确地使用状态类型。

在 API 数据处理中的应用

在处理 API 返回的数据时,我们可能需要根据数据的结构进行不同的类型处理。假设我们有一个 API,它可能返回两种不同结构的数据:一种是包含用户信息的对象,另一种是错误信息的对象。我们可以使用条件类型和映射类型来定义相应的类型:

type User = {
    id: number;
    name: string;
};

type ErrorResponse = {
    error: string;
};

type ApiResponse<T extends 'user' | 'error'> = T extends 'user' ? User : ErrorResponse;

function handleApiResponse<T extends 'user' | 'error'>(type: T, data: ApiResponse<T>) {
    if (type === 'user') {
        console.log(`User: ${data.name}`);
    } else {
        console.log(`Error: ${data.error}`);
    }
}

// 模拟 API 调用
const userData: User = { id: 1, name: 'Bob' };
const errorData: ErrorResponse = { error: 'Something went wrong' };

handleApiResponse('user', userData);
handleApiResponse('error', errorData);

这里 ApiResponse<T> 根据传入的类型参数 T 来确定返回的数据类型是 User 还是 ErrorResponsehandleApiResponse 函数根据不同的类型来处理数据,提高了代码的类型安全性。

在表单验证中的应用

在表单验证场景中,我们可以使用条件类型和映射类型来定义表单数据的类型以及验证规则。例如,我们有一个简单的登录表单,包含用户名和密码字段:

type LoginForm = {
    username: string;
    password: string;
};

type ValidationRules<T> = {
    [P in keyof T]: T[P] extends string ? (value: string) => boolean : never;
};

const loginValidationRules: ValidationRules<LoginForm> = {
    username: (value) => value.length > 0,
    password: (value) => value.length >= 6
};

function validateForm<T extends object>(form: T, rules: ValidationRules<T>) {
    let isValid = true;
    for (let key in form) {
        if (Object.prototype.hasOwnProperty.call(form, key)) {
            const value = form[key];
            const rule = rules[key];
            if (rule && typeof rule === 'function') {
                if (!rule(value as string)) {
                    isValid = false;
                    break;
                }
            }
        }
    }
    return isValid;
}

const loginFormData: LoginForm = { username: 'test', password: '123456' };
const isValid = validateForm(loginFormData, loginValidationRules);
console.log(isValid); 

在上述代码中,ValidationRules<T> 使用映射类型为 LoginForm 的每个字符串类型属性定义了一个验证函数类型。validateForm 函数使用这些验证规则来验证表单数据的有效性。

在工具函数库中的应用

在开发工具函数库时,条件类型和映射类型可以帮助我们创建更通用、类型安全的函数。例如,我们有一个函数,它可以从对象中提取指定属性组成新的对象。我们可以使用条件类型和映射类型来定义这个函数的类型:

type PickByKeys<T, K extends keyof T> = {
    [P in K]: T[P];
};

function pick<T, K extends keyof T>(obj: T, keys: K[]): PickByKeys<T, K> {
    const result = {} as PickByKeys<T, K>;
    keys.forEach(key => {
        if (obj.hasOwnProperty(key)) {
            result[key] = obj[key];
        }
    });
    return result;
}

interface Product {
    name: string;
    price: number;
    description: string;
}

const product: Product = { name: 'Book', price: 10, description: 'A good book' };
const pickedProduct = pick(product, ['name', 'price']); 
// pickedProduct 是 { name: string; price: number; }

在这个例子中,PickByKeys<T, K> 使用映射类型根据传入的属性名 KT 类型中提取相应的属性组成新类型。pick 函数根据这个类型定义来实现从对象中提取指定属性的功能,保证了类型的一致性和安全性。

条件类型与映射类型的陷阱与注意事项

条件类型中的分布式行为可能导致的问题

条件类型在联合类型上的分布式行为虽然强大,但有时也会带来意想不到的结果。例如:

type IsString<T> = T extends string ? true : false;

type StringOrNumber = string | number;
type StringOrNumberCheck = IsString<StringOrNumber>; 
// StringOrNumberCheck 是 true | false

// 假设我们期望的是一个判断联合类型是否全是字符串的类型
type AllStrings<T> = T extends string ? true : false;
type AllStringsCheck = AllStrings<StringOrNumber>; 
// AllStringsCheck 也是 true | false,而不是 false

AllStrings 类型中,我们期望它能判断联合类型是否全是字符串类型,但由于分布式行为,它实际上是对联合类型中的每个类型进行判断,然后得到联合结果。如果要实现真正判断联合类型是否全是字符串类型,可以这样做:

type AllStrings<T> = [T] extends [string] ? true : false;

type StringOrNumber = string | number;
type AllStringsCheck = AllStrings<StringOrNumber>; 
// AllStringsCheck 是 false

这里使用了将联合类型 T 放在数组中的技巧,这样就不会触发分布式行为,而是整体判断 T 是否可以赋值给 string

映射类型中的属性遍历顺序

在映射类型中,属性的遍历顺序是不确定的。例如:

interface MyObject {
    a: string;
    b: number;
    c: boolean;
}

type MappedObject = {
    [P in keyof MyObject]: MyObject[P];
};

// 无法保证 MappedObject 中属性的顺序与 MyObject 一致

虽然在大多数情况下,属性顺序不影响类型的使用,但在一些依赖属性顺序的场景(如某些序列化或反序列化操作)中,需要特别注意。

条件类型和映射类型的性能问题

当条件类型和映射类型使用过于复杂或嵌套过深时,可能会导致编译性能问题。例如,深度递归的条件类型或映射类型可能会使编译时间显著增加。在实际开发中,应尽量避免不必要的复杂类型操作。如果确实需要复杂的类型处理,可以考虑将其分解为多个简单的类型操作,以提高编译效率。

类型推断与条件类型、映射类型的交互

在使用条件类型和映射类型时,类型推断可能会出现一些微妙的问题。例如:

type MyType<T> = T extends { a: number } ? { b: string } : { c: boolean };

function test<T>(arg: T): MyType<T> {
    // 这里如果没有正确的类型断言,TypeScript 可能无法正确推断返回类型
    if ('a' in arg) {
        return { b: 'test' } as MyType<T>;
    } else {
        return { c: true } as MyType<T>;
    }
}

test 函数中,由于条件类型的存在,TypeScript 的类型推断可能无法完全准确地确定返回值类型。这时可能需要使用类型断言来确保类型的正确性。

与其他类型系统特性的兼容性

条件类型和映射类型需要与 TypeScript 的其他类型系统特性(如接口、类型别名、泛型约束等)协同工作。在组合使用时,要注意它们之间的兼容性。例如,在使用泛型约束和映射类型时,约束条件可能会影响映射类型的行为。如果约束条件设置不当,可能会导致类型错误或不符合预期的类型结果。

总结

条件类型和映射类型是 TypeScript 中非常强大的高级类型特性。条件类型允许我们根据条件在类型层面进行选择,而映射类型则可以基于现有类型快速创建新类型。它们在实际开发中有广泛的应用场景,如 React 组件开发、API 数据处理、表单验证和工具函数库开发等。然而,在使用过程中也需要注意一些陷阱和事项,如条件类型的分布式行为、映射类型的属性遍历顺序、性能问题、类型推断的交互以及与其他类型系统特性的兼容性等。通过合理运用这些高级类型特性,并避免潜在的问题,我们可以编写出更健壮、类型安全的 TypeScript 代码。在日常开发中,不断积累对条件类型和映射类型的使用经验,有助于提升我们在前端开发中对复杂类型处理的能力,从而提高代码的质量和可维护性。