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

深入理解Typescript的映射类型

2023-07-314.9k 阅读

什么是映射类型

在TypeScript中,映射类型是一种非常强大的类型转换工具。它允许我们基于现有的类型创建新的类型。简单来说,就是以一种映射的方式,将一个类型的属性映射到另一个类型的属性上。

例如,假设我们有一个简单的类型User

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

现在,如果我们想要创建一个新的类型,这个类型的属性和User一样,但是所有属性的值都是只读的。使用映射类型就可以很轻松地实现:

type ReadonlyUser = {
    readonly [P in keyof User]: User[P];
};

在这个例子中,[P in keyof User]表示遍历User类型的所有属性键,P就是每个属性键。然后User[P]获取对应属性的值类型,我们给这个新类型的属性加上readonly修饰符,就得到了一个所有属性只读的新类型ReadonlyUser

映射类型的语法详解

  1. keyof操作符 keyof操作符用于获取一个类型的所有属性键,返回一个联合类型。例如:
type Person = {
    name: string;
    age: number;
};
type PersonKeys = keyof Person; // "name" | "age"
  1. in关键字 in关键字在映射类型中用于遍历联合类型。结合keyof,就可以遍历一个类型的所有属性键。比如上面的[P in keyof User]P会依次取User类型的每个属性键。
  2. 类型映射的具体实现 在映射类型的定义中,我们通过[P in keyof SomeType]获取属性键,然后通过SomeType[P]获取对应的值类型,在此基础上可以对属性进行各种修改,如添加修饰符(readonlyoptional等),或者修改值类型。

常见的映射类型应用

  1. Readonly映射类型 前面已经介绍过将一个类型的所有属性变为只读的例子。再看一个更通用的实现:
type MyReadonly<T> = {
    readonly [P in keyof T]: T[P];
};
type Point = { x: number; y: number };
type ReadonlyPoint = MyReadonly<Point>;
let rp: ReadonlyPoint = { x: 10, y: 20 };
// rp.x = 30; // 报错,属性“x”为只读
  1. Partial映射类型 Partial映射类型用于将一个类型的所有属性变为可选的。其实现如下:
type MyPartial<T> = {
    [P in keyof T]?: T[P];
};
type FullUser = {
    name: string;
    age: number;
    address: string;
};
type PartialUser = MyPartial<FullUser>;
let partialUser: PartialUser = { name: 'John' };
  1. Required映射类型Partial相反,Required映射类型用于将一个类型的所有可选属性变为必选。实现如下:
type MyRequired<T> = {
    [P in keyof T]-?: T[P];
};
type OptionalUser = {
    name?: string;
    age?: number;
};
type RequiredUser = MyRequired<OptionalUser>;
let requiredUser: RequiredUser = { name: 'Jane', age: 30 };
// let badUser: RequiredUser = {}; // 报错,缺少属性“name”和“age”

这里-?表示移除属性的可选修饰符。

  1. Pick映射类型 Pick映射类型用于从一个类型中选取部分属性,创建一个新类型。实现如下:
type MyPick<T, K extends keyof T> = {
    [P in K]: T[P];
};
type AllUser = {
    name: string;
    age: number;
    email: string;
    phone: string;
};
type BasicUser = MyPick<AllUser, 'name' | 'age'>;
let basicUser: BasicUser = { name: 'Bob', age: 25 };

这里K extends keyof T确保我们选取的属性键确实存在于T类型中。

  1. Omit映射类型 Omit映射类型与Pick相反,用于从一个类型中排除部分属性,创建一个新类型。实现如下:
type MyOmit<T, K extends keyof T> = {
    [P in Exclude<keyof T, K>]: T[P];
};
type AllProduct = {
    id: number;
    name: string;
    price: number;
    description: string;
};
type SimpleProduct = MyOmit<AllProduct, 'description'>;
let simpleProduct: SimpleProduct = { id: 1, name: 'Widget', price: 10 };

这里Exclude<keyof T, K>用于排除K中的属性键,只保留T中不在K里的属性键。

映射类型与函数类型

  1. 对函数类型属性的映射 假设我们有一个类型,其中包含函数类型的属性:
type Actions = {
    add: (a: number, b: number) => number;
    multiply: (a: number, b: number) => number;
};

如果我们想创建一个新类型,这个类型的函数属性返回值都变为void,可以这样实现:

type VoidActions = {
    [P in keyof Actions]: (
        ...args: Parameters<Actions[P]>
    ) => void;
};

这里Parameters<Actions[P]>用于获取Actions[P]函数类型的参数类型,然后我们定义新函数类型,返回值为void

  1. 使用映射类型创建函数重载 映射类型还可以用于创建函数重载。例如,我们有一个根据不同类型执行不同操作的函数:
type Operation<T> = {
    [P in keyof T]: (value: T[P]) => void;
};
function performOperation<T>(operation: Operation<T>) {
    // 具体实现
}
let stringOperation: Operation<{ message: string }> = {
    message: (value) => console.log('String:', value)
};
performOperation(stringOperation);

通过映射类型,我们可以很方便地为不同类型定义不同的操作逻辑,并且在函数调用时进行类型检查。

映射类型的嵌套与递归

  1. 嵌套映射类型 映射类型可以嵌套使用。比如,我们有一个复杂的类型NestedObject
type NestedObject = {
    a: { x: number; y: number };
    b: { p: string; q: string };
};

如果我们想将NestedObject中所有内部对象的属性变为只读,可以这样实现:

type DeepReadonly<T> = {
    readonly [P in keyof T]: T[P] extends object
      ? DeepReadonly<T[P]>
       : T[P];
};
type DeepReadonlyNested = DeepReadonly<NestedObject>;
let deepReadonlyObj: DeepReadonlyNested = {
    a: { x: 1, y: 2 },
    b: { p: 'hello', q: 'world' }
};
// deepReadonlyObj.a.x = 3; // 报错,属性“x”为只读

这里通过递归调用DeepReadonly,实现了对嵌套对象的深度只读转换。

  1. 递归映射类型的注意事项 在使用递归映射类型时,要注意避免无限递归。例如,如果不小心定义了如下类型:
type InfiniteRecursion<T> = {
    [P in keyof T]: InfiniteRecursion<T[P]>;
};

这会导致编译错误,因为会陷入无限递归。所以在递归映射类型中,要确保有终止条件,像上面DeepReadonly例子中,通过T[P] extends object来判断是否继续递归。

映射类型与条件类型的结合

  1. 条件映射类型 结合条件类型,映射类型可以实现更复杂的类型转换。例如,我们有一个类型MaybeNumber
type MaybeNumber = string | number;
type ConvertToBoolean<T> = {
    [P in keyof T]: T[P] extends number ? boolean : T[P];
};
type Example = {
    value1: number;
    value2: string;
};
type ConvertedExample = ConvertToBoolean<Example>;
// ConvertedExample = { value1: boolean; value2: string }

这里根据属性值类型是否为number,将其转换为boolean,否则保持不变。

  1. 条件映射类型的应用场景 这种结合在处理数据转换或者根据不同类型进行不同操作时非常有用。比如在处理API响应数据时,有些字段可能根据特定条件需要转换为不同类型,条件映射类型就可以帮助我们在类型层面进行这种转换,确保代码的类型安全性。

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

  1. 状态管理库中的应用 在Redux等状态管理库中,我们经常需要定义action types和action creators。映射类型可以帮助我们更高效地管理这些类型。例如:
// 定义action types
type CounterActions = {
    increment: void;
    decrement: void;
    reset: void;
};
// 使用映射类型创建action creators
type ActionCreators<T> = {
    [P in keyof T]: () => { type: P };
};
type CounterActionCreators = ActionCreators<CounterActions>;
let increment: CounterActionCreators['increment'] = () => ({ type: 'increment' });

通过映射类型,我们可以自动为每个action type生成对应的action creator类型,减少手动编写类型的工作量,同时提高代码的类型安全性。

  1. 数据持久化中的应用 在处理数据持久化,如将数据存储到本地存储或者数据库时,我们可能需要对数据进行序列化和反序列化。映射类型可以帮助我们确保序列化和反序列化后的数据类型一致。例如:
type Serializable<T> = {
    [P in keyof T]: T[P] extends Function ? never : T[P];
};
type UserData = {
    name: string;
    age: number;
    // 假设我们不想序列化这个方法
    greet: () => string;
};
type SerializableUserData = Serializable<UserData>;
// SerializableUserData = { name: string; age: number }

这样,通过映射类型排除了函数类型的属性,确保了数据在持久化过程中的类型兼容性。

映射类型的局限与注意事项

  1. 类型推断的局限性 虽然映射类型非常强大,但在某些复杂情况下,TypeScript的类型推断可能无法准确推断出类型。例如,当使用嵌套的映射类型和复杂的条件类型结合时,可能会出现类型推断不准确的情况。此时,可能需要手动指定类型来确保代码的正确性。
  2. 编译性能问题 复杂的映射类型,特别是包含递归和大量条件判断的映射类型,可能会导致编译时间变长。在实际项目中,要权衡功能需求和编译性能,避免过度使用复杂的映射类型。
  3. 可读性问题 复杂的映射类型定义可能会变得非常难以理解和维护。为了提高代码的可读性,建议对复杂的映射类型进行适当的注释,并且尽量将其拆分为多个简单的映射类型组合。

总结映射类型的要点

  1. 核心概念:映射类型允许基于现有类型创建新类型,通过遍历属性键并对属性进行转换来实现。
  2. 常见应用:包括ReadonlyPartialRequiredPickOmit等常见映射类型,用于对属性的只读、可选、必选等修饰以及属性的选取和排除。
  3. 与其他类型的结合:与函数类型结合可以处理函数属性的转换和重载,与条件类型结合能实现更复杂的类型转换,与嵌套和递归结合可处理复杂数据结构。
  4. 实际应用:在状态管理、数据持久化等实际项目场景中有广泛应用,能提高代码的类型安全性和开发效率。
  5. 注意事项:要注意类型推断的局限性、编译性能问题和可读性问题,合理使用映射类型,确保代码的质量和可维护性。

通过深入理解映射类型,我们可以在TypeScript项目中更灵活、高效地处理类型,编写出更健壮、可维护的代码。无论是小型项目还是大型企业级应用,映射类型都能为我们提供强大的类型编程能力。