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

TypeScript keyof与索引类型查询实战

2021-12-307.0k 阅读

TypeScript 中的 keyof 操作符

在 TypeScript 的类型系统中,keyof 操作符是一个强大的工具,它用于获取对象类型的所有键的联合类型。这在很多场景下都非常有用,尤其是当我们需要根据对象的键来进行类型推导或者类型约束的时候。

keyof 的基本语法

keyof 操作符跟在对象类型之后,语法形式为 keyof Type,这里的 Type 是一个对象类型。例如:

interface User {
  name: string;
  age: number;
  email: string;
}

type UserKeys = keyof User; 
// UserKeys 此时为 'name' | 'age' | 'email'

在上述代码中,我们定义了一个 User 接口,然后使用 keyof User 获取了 User 接口所有键的联合类型,并将其赋值给 UserKeys 类型别名。这样 UserKeys 类型就包含了 'name''age''email' 这三个字符串字面量类型组成的联合类型。

keyof 与索引签名

当对象类型使用索引签名时,keyof 操作符返回的类型会根据索引签名的类型而变化。比如:

interface StringMap {
  [key: string]: string;
}

type StringMapKeys = keyof StringMap; 
// StringMapKeys 为 string 类型

interface NumberMap {
  [key: number]: boolean;
}

type NumberMapKeys = keyof NumberMap; 
// NumberMapKeys 为 number 类型

StringMap 接口中,使用了字符串类型的索引签名,所以 keyof StringMap 返回 string 类型。而在 NumberMap 接口中,使用了数字类型的索引签名,keyof NumberMap 则返回 number 类型。

keyof 与泛型

keyof 操作符与泛型结合使用时,能极大地增强代码的灵活性和复用性。假设我们有一个函数,它接受一个对象和该对象的一个键,并返回对应键的值:

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

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

const product: Product = { id: 1, name: 'Widget', price: 10 };
const productName = getProperty(product, 'name'); 
// productName 类型为 string

getProperty 函数中,我们使用了两个泛型参数 TKT 代表传入对象的类型,K 则通过 K extends keyof T 约束为 T 类型对象的键类型。这样在函数内部,key 参数就只能是 obj 对象的合法键,并且函数返回值类型为 T[K],即 obj 对象中 key 对应的值的类型。

索引类型查询

索引类型查询是 TypeScript 类型系统中另一个重要的特性,它允许我们通过已知的对象类型和键类型来查询对应的值类型。这与 keyof 操作符密切相关,并且常常一起使用。

基本的索引类型查询

假设有一个对象类型,我们可以通过在对象类型后面使用 [键类型] 的方式来进行索引类型查询。例如:

interface Book {
  title: string;
  author: string;
  pages: number;
}

type TitleType = Book['title']; 
// TitleType 为 string 类型

type AuthorType = Book['author']; 
// AuthorType 为 string 类型

type PagesType = Book['pages']; 
// PagesType 为 number 类型

在上述代码中,我们通过 Book['title']Book['author']Book['pages'] 分别查询了 Book 接口中 titleauthorpages 键对应的值类型。

联合类型的索引类型查询

当我们使用联合类型进行索引类型查询时,TypeScript 会取联合类型中各个键对应值类型的联合。例如:

interface Employee {
  name: string;
  age: number;
  salary: number;
}

type NameOrAgeType = Employee['name' | 'age']; 
// NameOrAgeType 为 string | number 类型

这里 Employee['name' | 'age'] 表示查询 Employee 接口中 name 键和 age 键对应值类型的联合,即 string | number

索引类型查询与泛型

keyof 操作符一样,索引类型查询与泛型结合可以创造出非常灵活的代码。例如,我们想要一个函数,它能从给定对象中提取指定键的值,并返回这些值组成的数组:

function pick<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
  return keys.map(key => obj[key]);
}

interface Fruit {
  name: string;
  color: string;
  taste: string;
}

const apple: Fruit = { name: 'Apple', color: 'Red', taste: 'Sweet' };
const appleProps = pick(apple, ['name', 'color']); 
// appleProps 类型为 string[]

pick 函数中,通过 T 泛型表示对象的类型,K 泛型通过 K extends keyof T 约束为 T 对象的键类型。keys 参数接受一个 K 类型的数组,函数返回值类型为 T[K][],即 obj 对象中 keys 数组中各键对应值组成的数组。

keyof 与索引类型查询的实战应用

在数据验证中的应用

在开发过程中,数据验证是一个常见的需求。我们可以利用 keyof 和索引类型查询来实现类型安全的数据验证。例如,假设我们有一个用户注册表单的数据验证函数:

interface RegistrationForm {
  username: string;
  password: string;
  email: string;
}

function validateRegistrationForm(form: RegistrationForm) {
  const requiredFields: (keyof RegistrationForm)[] = ['username', 'password', 'email'];
  for (const field of requiredFields) {
    if (!form.hasOwnProperty(field)) {
      throw new Error(`${field} is required`);
    }
    const value = form[field];
    if (field === 'username' && typeof value!=='string') {
      throw new Error('Username must be a string');
    }
    if (field === 'password' && typeof value!=='string') {
      throw new Error('Password must be a string');
    }
    if (field === 'email' && typeof value!=='string') {
      throw new Error('Email must be a string');
    }
  }
  return true;
}

const validForm: RegistrationForm = { username: 'testuser', password: 'testpass', email: 'test@example.com' };
try {
  validateRegistrationForm(validForm);
  console.log('Form is valid');
} catch (error) {
  console.error(error.message);
}

const invalidForm: Partial<RegistrationForm> = { username: 'testuser' };
try {
  validateRegistrationForm(invalidForm);
  console.log('Form is valid');
} catch (error) {
  console.error(error.message);
}

在上述代码中,我们首先定义了 RegistrationForm 接口来表示注册表单的数据结构。然后在 validateRegistrationForm 函数中,使用 keyof RegistrationForm 获取所有必需字段的类型,并存储在 requiredFields 数组中。通过遍历这个数组,我们可以检查表单数据是否包含所有必需字段,并且可以根据每个字段的类型进行进一步的验证。

在 API 数据处理中的应用

当从 API 获取数据并将其映射到特定的 TypeScript 类型时,keyof 和索引类型查询非常有用。例如,假设我们有一个 API 返回用户信息,并且我们有一个 User 接口来表示用户数据的结构:

interface User {
  id: number;
  name: string;
  email: string;
}

async function fetchUser(): Promise<User> {
  const response = await fetch('/api/user');
  const data = await response.json();
  const user: User = {
    id: data['id'],
    name: data['name'],
    email: data['email']
  };
  return user;
}

fetchUser().then(user => {
  console.log(user.name);
});

然而,上述代码存在一个潜在问题,如果 API 返回的数据结构与 User 接口不完全匹配,TypeScript 编译器无法检测到错误。我们可以利用 keyof 和索引类型查询来改进这个问题:

interface User {
  id: number;
  name: string;
  email: string;
}

async function fetchUser(): Promise<User> {
  const response = await fetch('/api/user');
  const data = await response.json();
  const user: Partial<User> = {};
  const userKeys: (keyof User)[] = ['id', 'name', 'email'];
  for (const key of userKeys) {
    if (data.hasOwnProperty(key)) {
      user[key] = data[key];
    }
  }
  return user as User;
}

fetchUser().then(user => {
  console.log(user.name);
});

在改进后的代码中,我们首先定义了 userKeys 数组,包含 User 接口的所有键。然后通过遍历这个数组,我们检查 API 返回的数据中是否存在对应的键,如果存在则将其赋值给 user 对象。这样,如果 API 返回的数据缺少某个必需字段,我们可以更容易地发现并处理这个问题。

在 React 组件开发中的应用

在 React 开发中,我们经常需要处理组件的 props。keyof 和索引类型查询可以帮助我们确保 props 的类型安全。例如,假设我们有一个 Button 组件:

import React from'react';

interface ButtonProps {
  text: string;
  onClick: () => void;
  disabled: boolean;
}

const Button: React.FC<ButtonProps> = ({ text, onClick, disabled }) => {
  return (
    <button disabled={disabled} onClick={onClick}>
      {text}
    </button>
  );
};

const handleClick = () => {
  console.log('Button clicked');
};

const validProps: ButtonProps = { text: 'Click me', onClick: handleClick, disabled: false };
const invalidProps: Partial<ButtonProps> = { text: 'Click me' }; 

// 在实际使用中,TypeScript 会提示 invalidProps 缺少 onClick 和 disabled 属性
// <Button {...invalidProps} /> 

<Button {...validProps} />

在这里,我们定义了 ButtonProps 接口来描述 Button 组件的 props。通过使用 keyof ButtonProps,我们可以很方便地知道哪些属性是 Button 组件所期望的,并且在传递 props 时,TypeScript 会严格检查是否提供了所有必需的属性,从而提高代码的健壮性。

在类型安全的对象操作工具函数中的应用

我们可以创建一些通用的对象操作工具函数,利用 keyof 和索引类型查询来保证类型安全。例如,一个用于合并两个对象的函数:

function mergeObjects<T, U>(obj1: T, obj2: U): T & U {
  const result = {} as T & U;
  const keys1: (keyof T)[] = Object.keys(obj1) as (keyof T)[];
  const keys2: (keyof U)[] = Object.keys(obj2) as (keyof U)[];

  for (const key of keys1) {
    result[key] = obj1[key];
  }
  for (const key of keys2) {
    result[key] = obj2[key];
  }
  return result;
}

interface ObjectA {
  a: string;
  b: number;
}

interface ObjectB {
  b: string;
  c: boolean;
}

const objA: ObjectA = { a: 'hello', b: 123 };
const objB: ObjectB = { b: 'world', c: true };
const merged = mergeObjects(objA, objB); 
// merged 类型为 { a: string; b: string; c: boolean; }

mergeObjects 函数中,我们通过 Object.keys 获取对象的键数组,并将其类型断言为 (keyof T)[](keyof U)[]。然后通过遍历这两个键数组,将两个对象的属性合并到 result 对象中,并且 result 对象的类型为 T & U,保证了合并后对象的类型安全。

深入理解 keyof 和索引类型查询的底层原理

keyof 的底层实现

从 TypeScript 的底层实现来看,keyof 操作符是在类型检查阶段起作用的。当编译器遇到 keyof Type 时,它会分析 Type 对象类型的结构,收集所有的键名,并将其组成一个联合类型。对于具有普通属性的对象类型,这个过程相对直接。而对于具有索引签名的对象类型,情况会有所不同。

当对象类型具有字符串索引签名 [key: string]: ValueType 时,keyof 操作符返回 string 类型,因为理论上任何字符串都可以作为该对象的键。对于数字索引签名 [key: number]: ValueTypekeyof 返回 number 类型。这是因为在 JavaScript 中,对象的数字键在底层会被转换为字符串,但在 TypeScript 的类型系统中,我们将其视为数字类型来处理,以提供更精确的类型检查。

索引类型查询的底层实现

索引类型查询 Type[KeyType] 同样在类型检查阶段进行处理。编译器会根据 Type 对象类型和 KeyType 来查找对应的属性值类型。如果 KeyType 是一个联合类型,编译器会分别查找联合类型中每个键对应的属性值类型,并将它们合并成一个联合类型。

例如,对于 interface Example { a: string; b: number; }type ExampleABType = Example['a' | 'b'],编译器会分别查找 Examplea 键对应的值类型 stringb 键对应的值类型 number,然后将它们合并为 string | number 类型。

这种底层实现方式使得我们能够在编译时进行强大的类型推导和类型检查,从而在代码运行前发现潜在的类型错误,提高代码的可靠性和可维护性。

注意事项与常见问题

keyofObject.keys 的区别

虽然 keyofObject.keys 都与对象的键相关,但它们有着本质的区别。keyof 是 TypeScript 类型系统中的操作符,用于获取对象类型的键的联合类型,只在类型层面起作用,不会生成任何运行时代码。而 Object.keys 是 JavaScript 的内置函数,用于在运行时获取对象自身可枚举属性的键组成的数组。

例如:

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

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

// keyof Person 在类型层面,为 'name' | 'age'
// Object.keys(person) 在运行时,返回 ['name', 'age']

在使用时要注意,Object.keys 返回的数组元素类型是 string,而 keyof 返回的联合类型是由对象实际键的字符串字面量类型组成。

索引类型查询中的类型兼容性

在进行索引类型查询时,需要注意类型兼容性问题。例如,当我们有一个父类型和一个子类型,并且子类型扩展了父类型的属性:

interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

type AnimalNameType = Animal['name']; 
// AnimalNameType 为 string
type DogNameType = Dog['name']; 
// DogNameType 也为 string

// 但是如果我们尝试这样做:
type DogBreedType = Animal['breed']; 
// 这里会报错,因为 Animal 类型中不存在 'breed' 属性

在这种情况下,索引类型查询只能获取到目标类型中实际存在的属性类型。如果尝试查询不存在的属性类型,TypeScript 编译器会报错。

泛型与 keyof 和索引类型查询的复杂组合

当泛型与 keyof 和索引类型查询进行复杂组合时,可能会出现类型推断困难的问题。例如:

function getDeepProperty<T, K1 extends keyof T, K2 extends keyof T[K1]>(obj: T, key1: K1, key2: K2): T[K1][K2] {
  return obj[key1][key2];
}

interface Company {
  departments: {
    sales: {
      manager: string;
    };
  };
}

const company: Company = {
  departments: {
    sales: {
      manager: 'Alice'
    }
  }
};

const manager = getDeepProperty(company, 'departments','sales'); 
// 这里会报错,因为类型推断错误,实际应该是 getDeepProperty(company, 'departments','sales').manager

// 正确调用
const managerName = getDeepProperty(company, 'departments','sales').manager; 

在这种情况下,需要仔细检查泛型的约束和类型推断,确保代码的正确性。可以通过显式指定泛型类型或者添加更详细的类型注释来帮助编译器进行类型推断。

通过深入理解 keyof 操作符和索引类型查询,并注意上述的注意事项与常见问题,我们能够在 TypeScript 编程中更加灵活、准确地使用它们,编写出更健壮、类型安全的代码。无论是在小型项目还是大型企业级应用中,这些技术都能为我们的开发工作带来巨大的价值。