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

TypeScript 映射类型:keyof 和 in 的应用

2023-07-071.7k 阅读

TypeScript 映射类型基础概念

在 TypeScript 的类型系统中,映射类型是一种强大的工具,它允许我们基于已有的类型创建新的类型。映射类型的核心在于通过对已有类型的属性进行遍历和转换,从而生成新的类型结构。而 keyofin 关键字在这个过程中发挥着关键作用。

keyof 操作符用于获取一个类型的所有键名组成的联合类型。例如,假设有一个简单的接口 Person

interface Person {
  name: string;
  age: number;
  email: string;
}
type PersonKeys = keyof Person; 
// PersonKeys 类型为 'name' | 'age' | 'email'

这里,keyof Person 返回了 Person 接口所有属性名组成的联合类型。这为我们在映射类型中遍历属性提供了基础。

in 关键字则是用于在映射类型中进行属性遍历。它的作用类似于 JavaScript 中 for...in 循环在对象属性遍历上的作用,但在类型层面。例如,我们可以基于 Person 接口创建一个新的只读版本的类型:

interface Person {
  name: string;
  age: number;
  email: string;
}
type ReadonlyPerson = {
  readonly [K in keyof Person]: Person[K];
};

在上述代码中,[K in keyof Person] 表示对 Person 接口的每个属性进行遍历,K 是一个类型变量,代表每个属性名。readonly 关键字使得新类型 ReadonlyPerson 的属性都变为只读。Person[K] 则表示获取 Person 类型中 K 对应的属性类型。这样就创建了一个新的类型 ReadonlyPerson,它的结构与 Person 相同,但所有属性都是只读的。

映射类型的常见应用场景

创建只读类型

如前面提到的,通过映射类型可以轻松创建只读版本的类型。这在很多场景下非常有用,比如当我们希望某个对象在特定部分的代码中只能被读取而不能被修改时。假设我们有一个函数接收一个用户信息对象,但只希望在函数内部读取这个对象,而不允许修改它:

interface User {
  id: number;
  username: string;
  password: string;
}
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};
function printUserInfo(user: ReadonlyUser) {
  console.log(`User ID: ${user.id}, Username: ${user.username}`);
  // 下面这行代码会报错,因为 ReadonlyUser 类型的属性是只读的
  // user.id = 2; 
}
const myUser: User = { id: 1, username: 'John', password: 'pass123' };
printUserInfo(myUser as ReadonlyUser); 

在这个例子中,ReadonlyUser 类型保证了传入 printUserInfo 函数的对象属性不会被意外修改。

创建可选类型

我们还可以通过映射类型创建可选类型。例如,假设我们有一个 Product 接口,并且希望创建一个新的类型,其中所有属性都是可选的。可以这样实现:

interface Product {
  name: string;
  price: number;
  description: string;
}
type OptionalProduct = {
  [K in keyof Product]?: Product[K];
};
function updateProduct(product: OptionalProduct) {
  // 这里可以处理部分更新产品信息的逻辑
}
const partialProduct: OptionalProduct = { name: 'New Product' };
updateProduct(partialProduct);

OptionalProduct 类型中,? 符号使得每个属性都变为可选的。这在处理对象部分更新等场景下非常实用,我们不需要传递完整的对象,只需要传递需要更新的部分属性即可。

创建 Pick 和 Omit 类型

  1. Pick 类型Pick 类型用于从一个类型中挑选出部分属性组成新的类型。例如,我们有一个 Employee 接口,现在只想获取其中的 nameemail 属性:
interface Employee {
  name: string;
  age: number;
  email: string;
  department: string;
}
type PickEmployee = {
  [K in 'name' | 'email']: Employee[K];
};
// 也可以使用内置的 Pick 类型
type BuiltInPickEmployee = Pick<Employee, 'name' | 'email'>;

在自定义的 PickEmployee 类型中,通过指定 'name' | 'email' 作为 in 关键字后的联合类型,挑选出了 Employee 接口中的 nameemail 属性。而 TypeScript 内置的 Pick 类型实现原理也是基于映射类型,它的定义大致如下:

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

这里 T 是源类型,K 是要挑选的属性名联合类型,并且 K 必须是 T 的属性名联合类型的子集。

  1. Omit 类型Omit 类型则与 Pick 相反,它用于从一个类型中剔除部分属性。例如,我们想从 Employee 接口中剔除 agedepartment 属性:
interface Employee {
  name: string;
  age: number;
  email: string;
  department: string;
}
type OmitEmployee = {
  [K in Exclude<keyof Employee, 'age' | 'department'>]: Employee[K];
};
// 也可以使用内置的 Omit 类型
type BuiltInOmitEmployee = Omit<Employee, 'age' | 'department'>;

在自定义的 OmitEmployee 类型中,Exclude<keyof Employee, 'age' | 'department'> 用于排除 agedepartment 属性名,然后通过映射类型创建新的类型。TypeScript 内置的 Omit 类型定义大致如下:

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

这里先通过 ExcludeT 的属性名中排除 K,然后再使用 Pick 挑选剩余的属性。

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

基于条件类型转换属性类型

通过结合映射类型和条件类型,我们可以实现更复杂的类型转换。例如,假设我们有一个 Data 接口,其中部分属性是字符串类型,部分是数字类型,现在我们希望将所有字符串类型的属性转换为大写形式。可以这样实现:

interface Data {
  name: string;
  age: number;
  address: string;
}
type CapitalizeStringProperties<T> = {
  [K in keyof T]: T[K] extends string? Capitalize<T[K]> : T[K];
};
type TransformedData = CapitalizeStringProperties<Data>;
// TransformedData 类型为 { name: Capitalize<string>; age: number; address: Capitalize<string>; }

CapitalizeStringProperties 类型中,通过条件类型 T[K] extends string? Capitalize<T[K]> : T[K] 判断 T 类型中属性 K 的类型是否为字符串,如果是则使用 Capitalize 类型将其首字母大写,否则保持原类型不变。

过滤属性

结合条件类型和映射类型还可以实现过滤属性。例如,我们有一个 AllData 接口,其中包含字符串、数字和布尔类型的属性,现在我们只想保留数字类型的属性:

interface AllData {
  name: string;
  age: number;
  isActive: boolean;
  score: number;
}
type FilterNumberProperties<T> = {
  [K in keyof T as T[K] extends number? K : never]: T[K];
};
type OnlyNumbers = FilterNumberProperties<AllData>;
// OnlyNumbers 类型为 { age: number; score: number; }

FilterNumberProperties 类型中,as T[K] extends number? K : never 部分用于根据属性类型进行过滤。如果属性类型是数字,则保留该属性名 K,否则使用 never 类型将其排除。这样就实现了只保留数字类型属性的功能。

映射类型在函数参数和返回值中的应用

函数参数类型的映射

在函数参数类型方面,映射类型可以帮助我们创建更灵活的参数类型。例如,假设我们有一个函数 handleUser,它接收一个用户信息对象,并且根据不同的业务需求,我们可能需要这个对象的属性是只读的或者可选的。我们可以通过映射类型来定义这些不同的参数类型:

interface User {
  id: number;
  username: string;
  email: string;
}
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};
type OptionalUser = {
  [K in keyof User]?: User[K];
};
function handleUser(user: ReadonlyUser | OptionalUser) {
  if ('id' in user) {
    console.log(`User ID: ${user.id}`);
  }
}
const readonlyUser: ReadonlyUser = { id: 1, username: 'Alice', email: 'alice@example.com' };
const optionalUser: OptionalUser = { username: 'Bob' };
handleUser(readonlyUser);
handleUser(optionalUser);

这里通过映射类型创建了 ReadonlyUserOptionalUser 两种不同的类型,并将它们作为 handleUser 函数参数的联合类型,使得函数可以接收不同性质的用户信息对象。

函数返回值类型的映射

对于函数返回值类型,映射类型同样可以发挥作用。比如我们有一个函数 transformProduct,它接收一个 Product 对象,并且根据一定规则对属性进行转换,然后返回一个新的对象。我们可以通过映射类型来准确描述返回值的类型:

interface Product {
  name: string;
  price: number;
  quantity: number;
}
function transformProduct(product: Product): {
  [K in keyof Product]: K extends 'name'? string : number;
} {
  const newProduct = {} as {
    [K in keyof Product]: K extends 'name'? string : number;
  };
  newProduct.name = product.name.toUpperCase();
  newProduct.price = product.price * 2;
  newProduct.quantity = product.quantity + 1;
  return newProduct;
}
const product: Product = { name: 'Widget', price: 10, quantity: 5 };
const transformed = transformProduct(product);
console.log(transformed.name); 
console.log(transformed.price); 
console.log(transformed.quantity); 

在这个例子中,通过映射类型定义了 transformProduct 函数返回值的类型,其中 name 属性保持为字符串类型,但转换为大写形式,而 pricequantity 属性转换为数字类型并进行了相应的计算。

映射类型的高级技巧与注意事项

深层映射类型

有时候我们需要处理嵌套对象的映射类型。例如,假设我们有一个包含嵌套对象的 Company 接口:

interface Address {
  street: string;
  city: string;
}
interface Company {
  name: string;
  address: Address;
  employees: {
    name: string;
    age: number;
  }[];
}

如果我们想创建一个 ReadonlyCompany 类型,使得所有层级的属性都变为只读,可以这样实现:

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

DeepReadonly 类型中,通过递归的方式判断属性类型是否为对象,如果是则继续进行深层的只读映射,否则保持原类型并设置为只读。

注意映射类型的性能

虽然映射类型非常强大,但在使用时需要注意性能问题。当类型定义非常复杂,特别是涉及多层嵌套和大量属性的映射时,TypeScript 的类型检查器可能需要花费更多的时间来处理这些类型。例如,在一个大型项目中,如果频繁使用复杂的映射类型来定义全局的状态类型,可能会导致编译时间变长。因此,在实际应用中,要尽量保持映射类型的简洁性,避免过度复杂的嵌套和转换。

与其他类型工具的协同使用

映射类型通常需要与其他类型工具如条件类型、泛型等协同使用才能发挥最大功效。例如,在实现 PickOmit 类型时,就结合了映射类型、泛型和条件类型。在实际项目中,要根据具体的需求灵活组合这些类型工具,以实现最准确和高效的类型定义。同时,要注意不同类型工具之间的兼容性和相互影响,确保整个类型系统的一致性和稳定性。

通过深入理解 keyofin 关键字在映射类型中的应用,我们能够更加灵活和精确地定义 TypeScript 类型,从而提高代码的可维护性和可靠性,为大型前端项目的开发提供强大的类型支持。无论是创建只读类型、可选类型,还是结合条件类型进行复杂的属性转换和过滤,映射类型都为我们提供了丰富的手段来构建适应各种业务需求的类型结构。在实际开发过程中,不断积累对映射类型的使用经验,并注意性能和与其他类型工具的协同,将有助于我们编写出高质量的 TypeScript 代码。