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

TypeScript 使用 keyof 和 in 操作符构建映射类型

2021-08-087.4k 阅读

一、TypeScript 中的映射类型基础

在 TypeScript 开发中,映射类型是一项强大的功能,它允许我们基于现有的类型创建新类型。映射类型的核心在于能够对类型的属性进行遍历和转换。而 keyofin 操作符在构建映射类型时起着关键作用。

1.1 keyof 操作符

keyof 操作符用于获取一个类型的所有键(属性名),并生成一个联合类型。例如,假设有一个简单的类型 Person

type Person = {
    name: string;
    age: number;
    address: string;
};

type PersonKeys = keyof Person;
// PersonKeys 此时是 'name' | 'age' | 'address'

这里,keyof Person 生成了一个联合类型,包含了 Person 类型的所有属性名。这个联合类型在后续构建映射类型时非常有用,因为它提供了我们遍历属性的基础。

1.2 in 操作符

in 操作符在 TypeScript 映射类型中用于遍历类型的属性。结合 keyof 获取的属性名联合类型,我们可以使用 in 对每个属性进行操作。例如,创建一个新类型,将 Person 类型的所有属性值都变成 string 类型:

type Person = {
    name: string;
    age: number;
    address: string;
};

type StringifyPerson = {
    [K in keyof Person]: string;
};
// StringifyPerson 类型为 { name: string; age: string; address: string; }

在上述代码中,[K in keyof Person] 表示对 Person 类型的每个属性名(通过 keyof Person 获取)进行遍历,K 是一个类型变量,代表每个具体的属性名。通过这种方式,我们就可以基于现有的 Person 类型创建一个新的 StringifyPerson 类型,将所有属性值类型转换为 string

二、基本映射类型的构建

2.1 简单属性转换

除了像上面那样简单地改变属性值类型,我们还可以进行更复杂的属性转换。例如,假设我们有一个表示用户信息的类型 User,其中某些属性是可选的,我们想要创建一个新类型,将所有属性都变成必填的。

type User = {
    name?: string;
    age?: number;
};

type RequiredUser = {
    [K in keyof User]-?: User[K];
};
// RequiredUser 类型为 { name: string; age: number; }

这里 -? 表示移除属性的可选修饰符。通过 [K in keyof User] 遍历 User 类型的所有属性名,然后 -? 操作符将每个属性的可选性移除,最后 User[K] 获取原属性的值类型,从而构建出一个所有属性都必填的 RequiredUser 类型。

2.2 只读属性转换

有时我们可能需要将类型的所有属性都变成只读的。例如,对于一个 Point 类型:

type Point = {
    x: number;
    y: number;
};

type ReadonlyPoint = {
    readonly [K in keyof Point]: Point[K];
};
// ReadonlyPoint 类型为 { readonly x: number; readonly y: number; }

在这个例子中,我们通过在属性名前加上 readonly 关键字,同时利用 inkeyof 操作符遍历 Point 类型的所有属性,创建了一个新的只读类型 ReadonlyPoint

三、条件映射类型

3.1 基于条件的属性过滤

在实际开发中,我们可能需要根据某些条件来过滤属性。例如,假设有一个类型 AllTypes,它包含不同类型的属性,我们只想保留字符串类型的属性,并将它们转换为大写形式。

type AllTypes = {
    name: string;
    age: number;
    address: string;
};

type FilteredStringProps = {
    [K in keyof AllTypes as AllTypes[K] extends string? K : never]: Uppercase<AllTypes[K]>;
};
// FilteredStringProps 类型为 { NAME: string; ADDRESS: string; }

这里 as AllTypes[K] extends string? K : never 是一个条件类型。它会检查 AllTypes 类型中每个属性值是否为 string 类型,如果是,则保留该属性名 K,否则使用 never 来过滤掉该属性。最后,Uppercase<AllTypes[K]> 将字符串属性值转换为大写形式。

3.2 条件属性修改

除了过滤属性,我们还可以根据条件修改属性。例如,对于一个包含数字和字符串属性的类型 MixedProps,我们想要将数字属性的值加倍,字符串属性的值保持不变。

type MixedProps = {
    num1: number;
    str1: string;
    num2: number;
};

type ModifiedProps = {
    [K in keyof MixedProps]: MixedProps[K] extends number? MixedProps[K] * 2 : MixedProps[K];
};
// ModifiedProps 类型为 { num1: number; str1: string; num2: number; }
// 实际值:如果原 num1 为 5,修改后 num1 为 10

在这个例子中,通过 MixedProps[K] extends number? MixedProps[K] * 2 : MixedProps[K] 条件类型,对 MixedProps 类型的每个属性进行检查。如果属性值是数字类型,则将其值加倍;如果是其他类型(这里只有字符串类型),则保持不变。

四、映射类型与泛型的结合

4.1 泛型映射类型基础

将泛型与映射类型结合可以大大提高类型的复用性。例如,我们可以创建一个通用的 MakeRequired 类型,它可以将任何类型的所有属性都变成必填的。

type MakeRequired<T> = {
    [K in keyof T]-?: T[K];
};

type OptionalUser = {
    name?: string;
    age?: number;
};

type RequiredUser = MakeRequired<OptionalUser>;
// RequiredUser 类型为 { name: string; age: number; }

这里,MakeRequired 是一个泛型类型,T 是泛型参数。通过 [K in keyof T]-?: T[K],我们可以对传入的任意类型 T 的所有属性进行操作,移除属性的可选修饰符,从而将其所有属性变成必填的。

4.2 复杂泛型映射类型

再看一个更复杂的例子,假设我们有一个类型 DataWithMeta,它包含数据和元数据,元数据中有一个 status 属性。我们想要创建一个通用的类型,根据 status 的值来过滤数据。

type DataWithMeta<T> = {
    data: T;
    meta: {
        status: 'active' | 'inactive';
    };
};

type FilteredData<T> = {
    [K in keyof T as DataWithMeta<T>[K]['meta']['status'] extends 'active'? K : never]: T[K]['data'];
};

type ExampleData = {
    user1: {
        data: { name: string };
        meta: { status: 'active' };
    };
    user2: {
        data: { age: number };
        meta: { status: 'inactive' };
    };
};

type ActiveData = FilteredData<ExampleData>;
// ActiveData 类型为 { user1: { name: string }; }

在上述代码中,FilteredData 是一个泛型映射类型。[K in keyof T as DataWithMeta<T>[K]['meta']['status'] extends 'active'? K : never] 这个部分根据 DataWithMeta<T>[K]['meta']['status'] 是否为 active 来过滤属性。如果是 active,则保留该属性名 K,否则使用 never 过滤掉。最后 T[K]['data'] 获取对应的数据部分。

五、映射类型在实际项目中的应用

5.1 API 响应数据处理

在前端开发中,经常需要处理 API 返回的数据。假设我们有一个 API 会返回不同类型的用户数据,并且数据结构中有一些可选字段。我们可以使用映射类型来规范化数据结构,确保某些字段是必填的。

// API 响应数据类型
type ApiUserResponse = {
    id?: number;
    name?: string;
    email?: string;
};

// 规范化后的用户类型
type NormalizedUser = {
    id: number;
    name: string;
    email: string;
};

// 使用映射类型创建规范化数据
type NormalizeUser = {
    [K in keyof NormalizedUser]: ApiUserResponse[K] extends undefined? never : ApiUserResponse[K];
};

// 假设 API 返回的数据
const apiData: ApiUserResponse = {
    id: 1,
    name: 'John',
    email: 'john@example.com'
};

const normalizedData: NormalizeUser = apiData as NormalizeUser;
// 这里通过映射类型确保了 apiData 中的属性符合 NormalizedUser 的必填要求

通过这种方式,我们可以在使用 API 返回的数据之前,利用映射类型对数据进行必要的验证和规范化,避免在后续代码中出现因属性缺失而导致的错误。

5.2 表单数据验证

在处理表单数据时,映射类型也非常有用。例如,我们有一个注册表单,用户需要填写用户名、密码和确认密码。我们可以使用映射类型来验证表单数据的类型和一致性。

type RegistrationForm = {
    username: string;
    password: string;
    confirmPassword: string;
};

type ValidateForm<T> = {
    [K in keyof T]: T[K] extends string? T[K] : never;
} & {
    password: T['password'] extends T['confirmPassword']? T['password'] : never;
};

const formData: RegistrationForm = {
    username: 'user1',
    password: 'pass123',
    confirmPassword: 'pass123'
};

const validData: ValidateForm<RegistrationForm> = formData as ValidateForm<RegistrationForm>;
// 通过映射类型验证了表单数据的类型和密码一致性

在这个例子中,ValidateForm 首先使用 [K in keyof T]: T[K] extends string? T[K] : never 确保所有属性值都是字符串类型。然后通过 password: T['password'] extends T['confirmPassword']? T['password'] : never 验证密码和确认密码是否一致。

六、映射类型的陷阱与注意事项

6.1 类型推断问题

在使用映射类型时,可能会遇到类型推断不准确的问题。例如,当使用复杂的条件类型和泛型结合时,TypeScript 的类型推断可能无法正确推导出最终类型。

type ConditionalType<T> = {
    [K in keyof T as T[K] extends string? K : never]: T[K];
};

function processData<T>(data: T): ConditionalType<T> {
    return data as ConditionalType<T>;
}

const mixedData = {
    name: 'Alice',
    age: 30
};

const result = processData(mixedData);
// 这里 TypeScript 可能无法准确推断出 result 的类型,可能会导致潜在错误

为了解决这个问题,我们可以明确地指定类型,或者使用类型断言来确保类型的正确性。例如:

const result: ConditionalType<typeof mixedData> = processData(mixedData);

6.2 性能问题

虽然映射类型在功能上非常强大,但在某些情况下可能会影响性能。当处理非常大的类型或者嵌套的映射类型时,TypeScript 的类型检查器可能需要花费更多的时间来进行类型推导和验证。

// 非常大的类型示例
type LargeObject = {
    [key: string]: number;
};

type TransformedLargeObject = {
    [K in keyof LargeObject]: LargeObject[K] * 2;
};
// 这里构建 TransformedLargeObject 类型可能会对性能产生一定影响

为了避免性能问题,尽量避免不必要的复杂映射类型,特别是在性能敏感的代码区域。如果可能,可以将复杂的映射类型拆分成多个简单的步骤,逐步进行类型转换和处理。

6.3 与其他类型工具的兼容性

在实际项目中,我们通常会结合多种 TypeScript 类型工具使用映射类型,如 utility - types 库中的工具类型。有时可能会出现兼容性问题。例如,Required 工具类型和我们自己实现的 MakeRequired 映射类型可能在某些复杂类型上表现不一致。

import { Required } from 'utility-types';

type MyType = {
    subType: {
        prop1?: string;
    };
};

type MyRequired1 = Required<MyType>;
type MyRequired2 = MakeRequired<MyType>;
// MyRequired1 和 MyRequired2 在处理嵌套类型时可能有不同的行为

在这种情况下,需要仔细研究不同工具类型的实现细节,确保在项目中使用时的一致性。可以通过编写测试用例来验证不同工具类型在各种情况下的行为,以便及时发现和解决兼容性问题。

通过深入理解 keyofin 操作符构建映射类型,我们可以在前端开发中更加灵活和高效地处理类型,提高代码的健壮性和可维护性。但同时也要注意在实际应用中可能遇到的各种问题,合理运用这些强大的工具。