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

映射类型在TypeScript中的应用

2023-08-095.9k 阅读

映射类型基础概念

在 TypeScript 中,映射类型是一种强大的类型操作工具,它允许我们基于已有的类型创建新的类型。简单来说,映射类型就是通过 “映射” 已有类型的属性,来创建一个新的类型。

举个最基础的例子,假设我们有一个简单的类型 User

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

现在,如果我们想要创建一个新的类型,这个类型和 User 结构一样,但是所有属性都是可选的。在没有映射类型之前,我们可能需要手动一个一个地把属性改成可选:

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

但是有了映射类型,我们可以这样做:

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

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

type OptionalUser = Optional<User>;

在上述代码中,Optional 就是一个映射类型。[P in keyof T] 表示对 T 类型的每一个属性键(keyof T 获取 T 的所有属性键)进行遍历,P 代表每次遍历的属性键。? 让属性变为可选,T[P] 表示属性的值类型和 T 中对应属性的值类型一致。

映射类型的语法解析

  1. [P in keyof T] 这部分是映射类型的核心遍历部分。keyof T 获取类型 T 的所有属性键,形成一个联合类型。例如,对于前面的 User 类型,keyof User 的结果就是 'name' | 'age' | 'email'P in keyof T 则是对这个联合类型进行遍历,每次将联合类型中的一个值赋给 P

  2. 属性修饰符 在属性键 P 前面可以添加属性修饰符,比如 ? 让属性变为可选,readonly 让属性变为只读。例如,我们可以创建一个只读版本的 User 类型:

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

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

type ReadonlyUserType = ReadonlyUser<User>;

ReadonlyUser 映射类型中,readonly [P in keyof T] 使得新类型的每个属性都是只读的。

  1. 属性值类型 T[P] 用于指定新类型中属性 P 的值类型。它和原类型 T 中属性 P 的值类型保持一致。当然,我们也可以对其进行一些变换。比如,我们想把所有属性值类型都变成字符串类型:
type Stringify<T> = {
  [P in keyof T]: string;
};

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

type StringifiedUser = Stringify<User>;

这里,Stringify 映射类型将 User 类型的所有属性值类型都变为了 string

映射类型的高级应用

  1. Pick 和 Omit 类型 TypeScript 内置了一些非常实用的映射类型,PickOmit 就是其中两个。

Pick 用于从一个类型中选取部分属性来创建新类型。它的定义如下:

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

假设我们有一个 FullUser 类型:

type FullUser = {
  name: string;
  age: number;
  email: string;
  address: string;
  phone: string;
};

如果我们只想要 nameemail 属性,可以这样使用 Pick

type BasicUser = Pick<FullUser, 'name' | 'email'>;

Omit 则相反,它用于从一个类型中排除部分属性来创建新类型。Omit 的定义如下:

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

这里用到了 Exclude 类型,它用于从一个联合类型中排除另一个联合类型的成员。继续以 FullUser 为例,如果我们想排除 addressphone 属性:

type SimplifiedUser = Omit<FullUser, 'address' | 'phone'>;
  1. 条件类型与映射类型结合 条件类型可以和映射类型结合,创造出更强大的类型操作。比如,我们有一个类型 Types
type Types = {
  a: string;
  b: number;
  c: boolean;
};

现在我们想创建一个新类型,对于 Types 中值类型为 string 的属性,将其值类型变为 number,其他属性保持不变。这时候就可以结合条件类型和映射类型:

type Transform<T> = {
  [P in keyof T]: T[P] extends string? number : T[P];
};

type Types = {
  a: string;
  b: number;
  c: boolean;
};

type TransformedTypes = Transform<Types>;

Transform 映射类型中,T[P] extends string? number : T[P] 是一个条件类型。如果 T[P]string 类型,就将其变为 number,否则保持不变。

  1. 深层次映射类型 有时候,我们的类型可能是嵌套的,这时候就需要进行深层次的映射。例如,我们有一个嵌套类型 NestedUser
type NestedUser = {
  basic: {
    name: string;
    age: number;
  };
  contact: {
    email: string;
    phone: string;
  };
};

假设我们想让所有嵌套属性都变为可选,我们可以这样定义一个深层次映射类型:

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

type NestedUser = {
  basic: {
    name: string;
    age: number;
  };
  contact: {
    email: string;
    phone: string;
  };
};

type DeepOptionalUser = DeepOptional<NestedUser>;

DeepOptional 映射类型中,首先判断 T[P] 是否是对象类型,如果是,则递归调用 DeepOptional 对其进行处理,否则直接让属性变为可选。

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

  1. 数据层接口处理 在开发后端 API 接口时,前端需要定义相应的数据接口类型。假设我们有一个获取用户信息的接口,返回的数据可能有很多属性,但是在某些页面只需要部分属性。例如,用户列表页面可能只需要 nameage 属性。我们可以使用 Pick 映射类型来定义这个特定的接口类型:
// 假设后端返回的完整用户类型
type FullUser = {
  id: number;
  name: string;
  age: number;
  email: string;
  address: string;
  // 还有其他更多属性
};

// 用户列表页面需要的接口类型
type UserListItem = Pick<FullUser, 'name' | 'age'>;

这样,在处理用户列表数据时,类型定义更加精确,避免了引入不必要的属性。

  1. 状态管理中的类型处理 在使用 Redux 等状态管理库时,我们通常会定义 action 类型。有时候,不同的 action 可能对状态的更新方式不同。例如,我们有一个用户状态类型 UserState
type UserState = {
  name: string;
  age: number;
  email: string;
};

假设我们有一个 UPDATE_USER action,它可能只更新部分属性。我们可以使用 Partial 映射类型(Partial 其实就是前面提到的将所有属性变为可选的映射类型)来定义 action 的 payload 类型:

type UpdateUserAction = {
  type: 'UPDATE_USER';
  payload: Partial<UserState>;
};

这样,在 dispatch UPDATE_USER action 时,我们可以只传递需要更新的属性,而不是整个 UserState

  1. 组件库开发 在开发组件库时,不同的组件可能需要基于相同的基础类型进行一些定制。例如,我们有一个基础的 ButtonProps 类型:
type ButtonProps = {
  label: string;
  onClick: () => void;
  disabled: boolean;
  size: 'small' | 'medium' | 'large';
};

如果我们想创建一个 SubmitButtonProps 类型,它和 ButtonProps 类似,但是 label 属性有默认值,并且新增了一个 form 属性。我们可以这样做:

type SubmitButtonProps = Omit<ButtonProps, 'label'> & {
  label?: string;
  form: string;
};

这里先使用 Omit 排除 ButtonProps 中的 label 属性,然后重新定义一个可选的 label 属性,并添加新的 form 属性。

映射类型的性能考虑

虽然映射类型非常强大,但在使用时也需要考虑性能问题。因为映射类型本质上是在类型检查阶段进行操作,并不会在运行时产生额外的代码。然而,复杂的映射类型可能会导致类型检查时间变长。

例如,深度嵌套的映射类型或者多层条件类型与映射类型的组合,可能会让类型检查器花费更多的时间来推导类型。在大型项目中,这可能会影响开发效率,特别是在每次保存文件触发类型检查时。

为了缓解这个问题,我们可以尽量保持映射类型的简洁。如果可能,将复杂的类型操作拆分成多个简单的映射类型。例如,对于前面提到的深层次映射类型,如果嵌套层次非常深,可以分成多个层次的映射类型进行处理,而不是在一个映射类型中完成所有的深层次操作。

另外,合理使用类型别名和接口来组织类型定义,也有助于提高类型检查的效率。比如,对于一些常用的基础映射类型,可以定义成类型别名,然后在其他复杂类型中复用,这样可以减少重复的类型计算。

映射类型与其他类型工具的对比

  1. 与交叉类型和联合类型对比 交叉类型(&)用于将多个类型合并成一个类型,它要求新类型必须同时满足所有参与交叉的类型的属性。例如:
type A = {
  a: string;
};

type B = {
  b: number;
};

type AB = A & B;

AB 类型就必须同时有 a 属性(类型为 string)和 b 属性(类型为 number)。

联合类型(|)则表示一个值可以是多种类型中的一种。例如:

type C = string | number;

一个变量如果是 C 类型,它的值可以是字符串或者数字。

而映射类型主要是基于已有类型对属性进行变换来创建新类型。它和交叉类型、联合类型的应用场景有所不同。交叉类型和联合类型更侧重于类型的组合和取值可能性,而映射类型侧重于对已有类型属性的操作。

  1. 与类型守卫对比 类型守卫主要用于在运行时确定一个值的类型。例如,使用 typeof 类型守卫:
function printValue(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.length);
  } else {
    console.log(value.toFixed(2));
  }
}

这里通过 typeof 类型守卫在运行时判断 value 的类型,然后进行不同的操作。

映射类型是在类型检查阶段对类型进行操作,并不会影响运行时的逻辑。它主要用于更精确地定义类型,而类型守卫用于运行时的类型判断和分支处理。

映射类型的局限性

  1. 无法处理运行时数据 映射类型是在编译阶段进行类型推导和操作的,它无法处理运行时的数据。例如,我们不能根据运行时获取的属性键来动态地创建映射类型。假设我们有一个函数接收一个属性键数组,然后想根据这些属性键创建一个类似 Pick 的类型,这在映射类型中是无法直接实现的,因为映射类型的操作都基于静态类型信息。

  2. 复杂类型导致可读性下降 当映射类型变得非常复杂,特别是多层嵌套、结合复杂的条件类型时,代码的可读性会急剧下降。例如,下面这个复杂的映射类型:

type ComplexTransform<T> = {
  [P in keyof T]: T[P] extends { [key: string]: unknown }
    ? {
        [Q in keyof T[P]]: T[P][Q] extends string
          ? number
          : T[P][Q] extends number
          ? string
          : T[P][Q];
      }
    : T[P];
};

这样的代码对于其他开发者来说,理解起来非常困难,维护成本也很高。在实际开发中,需要谨慎使用这样复杂的映射类型,尽量通过拆分和注释等方式提高代码的可读性。

  1. 对某些类型操作支持有限 虽然映射类型可以对属性进行增删改等操作,但对于一些特殊的类型操作,比如对函数类型的参数和返回值进行复杂的变换,映射类型的支持就比较有限。例如,我们想创建一个映射类型,将函数类型的所有参数类型变为 string,返回值类型变为 number,这不是简单的映射类型能够轻松实现的,可能需要结合其他更复杂的类型工具和技巧。

总结映射类型的使用要点

  1. 理解基本语法 深入理解 [P in keyof T] 的遍历机制,以及属性修饰符(如 ?readonly)和属性值类型的指定方式,是正确使用映射类型的基础。只有掌握了这些基本语法,才能灵活运用映射类型进行各种类型操作。

  2. 结合实际场景 将映射类型应用到实际项目的不同场景中,如数据层接口处理、状态管理、组件库开发等。通过实际应用,加深对映射类型的理解,同时也能提高代码的可维护性和类型安全性。

  3. 注意性能和可读性 在使用映射类型时,要注意性能问题,避免过于复杂的映射类型导致类型检查时间过长。同时,要注重代码的可读性,对于复杂的映射类型,尽量进行拆分和注释,以便其他开发者能够理解和维护。

  4. 了解局限性 清楚映射类型的局限性,知道在哪些情况下映射类型无法满足需求,从而选择合适的其他类型工具或者技巧来解决问题。

总之,映射类型是 TypeScript 中一个非常强大的类型操作工具,通过合理使用它,可以大大提高我们代码的质量和开发效率,但同时也需要注意其使用的要点和局限性。