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

Typescript中的高级类型技巧

2022-11-236.2k 阅读

一、交叉类型(Intersection Types)

  1. 定义与基础用法 交叉类型是将多个类型合并为一个类型。通过 & 符号来实现。它表示一个对象同时满足多个类型的要求。例如,假设我们有两个类型 UserInfoAdminInfo
type UserInfo = {
  name: string;
  age: number;
};
type AdminInfo = {
  role: string;
};
type AdminUser = UserInfo & AdminInfo;
let admin: AdminUser = {
  name: 'John',
  age: 30,
  role: 'admin'
};

在上述代码中,AdminUser 类型是 UserInfoAdminInfo 的交叉类型。这意味着 AdminUser 类型的对象必须同时具备 UserInfo 类型的 nameage 属性,以及 AdminInfo 类型的 role 属性。 2. 应用场景 交叉类型在处理具有多种角色或特性的对象时非常有用。比如在一个应用中,有些用户既是普通用户又具备管理员权限。通过交叉类型,我们可以清晰地定义这种复合类型,使代码的类型约束更加准确。同时,在库的开发中,当一个函数接受的参数需要满足多个不同类型的条件时,交叉类型也能发挥作用。例如,一个函数可能既需要一个具有 id 属性的对象,又需要一个具有 name 属性的对象,我们就可以使用交叉类型来定义参数类型。 3. 与接口继承的区别 虽然接口继承也能实现类似的功能,但有一些本质区别。接口继承是一种 “是一个” 的关系,而交叉类型更强调 “同时具备”。例如,一个 Dog 接口继承自 Animal 接口,意味着 Dog “是一种” Animal。而交叉类型则是将不同特性合并。此外,接口继承只能继承自其他接口,而交叉类型可以合并多种类型,包括基本类型、接口、类型别名等。

二、联合类型(Union Types)

  1. 定义与基础用法 联合类型表示一个值可以是多种类型中的一种。通过 | 符号来定义。例如:
let value: string | number;
value = 'hello';
value = 100;

这里 value 变量的类型是 string | number,意味着它可以被赋值为字符串类型或者数字类型的值。 2. 类型保护与类型缩小 当使用联合类型时,TypeScript 提供了类型保护机制来在运行时确定变量的实际类型,从而缩小类型范围。常见的类型保护方式有 typeof 操作符、instanceof 操作符等。

function printValue(value: string | number) {
  if (typeof value ==='string') {
    console.log(value.length);
  } else {
    console.log(value.toFixed(2));
  }
}

在上述代码中,通过 typeof 类型保护,当 valuestring 类型时,我们可以访问 length 属性;当 valuenumber 类型时,我们可以调用 toFixed 方法。 3. 联合类型与类型兼容性 在 TypeScript 中,联合类型的兼容性规则比较特殊。如果一个类型可以赋值给联合类型中的任何一个类型,那么它就可以赋值给这个联合类型。例如,string 类型可以赋值给 string | number 联合类型,因为 string 是联合类型中的一种。同时,当一个函数接受联合类型作为参数时,它必须能够处理联合类型中的所有可能类型。

三、类型别名与接口的深入对比

  1. 类型别名的特性 类型别名不仅可以给对象类型起别名,还可以给基本类型、联合类型、交叉类型等起别名。例如:
type StringOrNumber = string | number;
type UserID = number;

类型别名还支持泛型,这在处理一些通用的类型结构时非常方便。比如:

type Pair<T> = [T, T];
let pair: Pair<number> = [1, 1];
  1. 接口的特性 接口主要用于定义对象的形状。接口可以继承多个接口,实现类似多重继承的效果。例如:
interface Shape {
  color: string;
}
interface Rectangle extends Shape {
  width: number;
  height: number;
}

接口还支持声明合并,即如果有多个同名接口,它们会自动合并为一个接口。例如:

interface User {
  name: string;
}
interface User {
  age: number;
}
let user: User = {
  name: 'Tom',
  age: 25
};
  1. 何时使用类型别名与接口 一般来说,如果是定义对象的结构,接口是一个很好的选择,因为它的语法更简洁,并且支持声明合并。当需要给基本类型、联合类型、交叉类型等起别名,或者使用泛型来定义一些通用类型结构时,类型别名更为合适。在实际项目中,也可以根据团队的代码风格和习惯来选择使用。

四、条件类型(Conditional Types)

  1. 定义与语法 条件类型基于一个条件来选择不同的类型。语法形式为 T extends U? X : Y,表示如果类型 T 可以赋值给类型 U,则返回类型 X,否则返回类型 Y。例如:
type IsString<T> = T extends string? true : false;
type Result1 = IsString<string>; // true
type Result2 = IsString<number>; // false
  1. 条件类型的分发特性 当条件类型的输入是联合类型时,会发生分发。例如:
type Unpack<T> = T extends Array<infer U>? U : T;
type Numbers = Unpack<number[]>; // number
type Mixed = Unpack<string | number[]>; // string | number

Mixed 的例子中,由于 string | number[] 是联合类型,条件类型会对联合类型中的每个类型进行分发处理,最终得到 string | number。 3. 条件类型的应用场景 条件类型在很多场景下都非常有用。比如在实现类型转换函数时,可以根据输入类型的不同返回不同的类型。在库的开发中,也可以根据用户传入的类型参数来动态生成合适的类型。例如,在一个数据获取库中,可以根据用户传入的是否为 nullundefined 来决定返回的数据类型是可空还是非空。

五、映射类型(Mapped Types)

  1. 定义与基础用法 映射类型允许我们基于已有的类型创建新类型,通过对已有类型的每个属性进行相同的变换。例如,将一个对象类型的所有属性变为只读:
type ReadonlyUser = {
  readonly [P in keyof User]: User[P];
};
type User = {
  name: string;
  age: number;
};
let readonlyUser: ReadonlyUser = {
  name: 'Alice',
  age: 28
};
// readonlyUser.name = 'Bob'; // 报错,只读属性不能被重新赋值

在上述代码中,ReadonlyUser 类型通过 [P in keyof User] 遍历 User 类型的所有属性键,并将每个属性变为只读。 2. 映射修饰符 除了 readonly,还可以使用 ? 来将属性变为可选。例如,将一个对象类型的所有属性变为可选:

type OptionalUser = {
  [P in keyof User]?: User[P];
};
  1. 应用场景 映射类型在很多场景下都能发挥作用。比如在处理 API 响应数据时,可能需要将某些属性变为只读或可选。在开发可复用的组件库时,通过映射类型可以根据不同的需求动态生成不同的类型,提高代码的灵活性和可维护性。

六、索引类型(Index Types)

  1. 索引类型查询操作符 keyof keyof 操作符用于获取一个类型的所有键。例如:
type User = {
  name: string;
  age: number;
};
type UserKeys = keyof User; // 'name' | 'age'

这里 UserKeys 是一个联合类型,包含了 User 类型的所有属性键。 2. 索引访问操作符 T[K] T[K] 操作符用于获取类型 T 中键 K 对应的类型。例如:

type User = {
  name: string;
  age: number;
};
type NameType = User['name']; // string
  1. 索引类型的应用场景 索引类型在实现一些通用的函数或类型时非常有用。比如,实现一个根据对象的键获取对应值的函数,我们可以利用索引类型来确保类型安全:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
let user = {
  name: 'Eve',
  age: 32
};
let name = getProperty(user, 'name'); // name 的类型为 string

在上述代码中,通过 K extends keyof T 确保 keyobj 类型的合法键,然后返回 T[K] 类型的值,保证了类型安全。

七、高级类型的组合使用

  1. 交叉类型与联合类型的组合 交叉类型和联合类型可以组合使用来实现更复杂的类型定义。例如,假设我们有一个函数,它接受一个对象,这个对象要么是具有 name 属性的用户对象,要么是具有 title 属性的文章对象,并且都要有 id 属性。我们可以这样定义:
type User = {
  id: number;
  name: string;
};
type Article = {
  id: number;
  title: string;
};
type UserOrArticle = (User & { id: number }) | (Article & { id: number });
function printInfo(item: UserOrArticle) {
  if ('name' in item) {
    console.log(`User: ${item.name}`);
  } else {
    console.log(`Article: ${item.title}`);
  }
}
let user: User = { id: 1, name: 'Charlie' };
let article: Article = { id: 2, title: 'TypeScript Advanced' };
printInfo(user);
printInfo(article);

在上述代码中,UserOrArticle 类型是交叉类型和联合类型的组合,通过 in 操作符作为类型保护来处理不同类型的对象。 2. 条件类型与映射类型的组合 条件类型和映射类型组合可以实现非常强大的类型转换。例如,我们可以实现一个将对象类型中所有字符串类型的属性变为大写的类型:

type UppercaseStringProperties<T> = {
  [P in keyof T]: T[P] extends string? Uppercase<T[P]> : T[P];
};
type User = {
  name: string;
  age: number;
};
type TransformedUser = UppercaseStringProperties<User>;
// TransformedUser 类型为 { name: 'STRING'; age: number; }

在上述代码中,通过映射类型遍历 User 类型的所有属性,再结合条件类型判断属性类型是否为字符串,如果是则转换为大写。 3. 实际项目中的应用案例 在一个大型的前端项目中,我们可能会有一个数据请求模块。假设我们有不同类型的 API 响应数据,通过联合类型和条件类型,我们可以根据请求的不同动态生成合适的响应数据类型。同时,利用映射类型可以对响应数据的属性进行处理,比如将某些属性变为只读,以确保数据的不可变性。在后端开发中,对于数据库模型的操作,也可以利用这些高级类型技巧来保证数据的类型安全和一致性。例如,在一个基于 TypeScript 的 Node.js 项目中,通过索引类型和条件类型来实现数据库查询结果的类型匹配,避免在处理数据时出现类型错误。

八、类型推断与类型断言

  1. 类型推断 TypeScript 具有强大的类型推断能力,它可以根据变量的赋值、函数的返回值等自动推断出类型。例如:
let num = 10; // num 被推断为 number 类型
function add(a, b) {
  return a + b;
}
let result = add(5, 3); // result 被推断为 number 类型

在函数参数没有显式声明类型时,TypeScript 会根据传入的参数类型进行推断。并且在函数返回值没有显式声明类型时,会根据 return 语句的返回值类型进行推断。 2. 类型断言 类型断言用于手动指定一个值的类型。当 TypeScript 的类型推断无法满足我们的需求时,我们可以使用类型断言。有两种语法形式:<Type>valuevalue as Type。例如:

let someValue: any = 'this is a string';
let strLength: number = (<string>someValue).length;
// 或者
let strLength2: number = (someValue as string).length;

需要注意的是,类型断言只是告诉编译器 “我知道这个值是什么类型,你按我说的来”,并不会在运行时进行类型检查。所以使用类型断言时要确保断言的正确性,否则可能会导致运行时错误。 3. 类型推断与断言的权衡 在大多数情况下,应该优先使用类型推断,因为它可以减少代码中的冗余类型声明,使代码更简洁,同时也能充分利用 TypeScript 的类型检查机制。而类型断言则用于一些特殊情况,比如与第三方库交互时,库的类型定义不完善,需要手动指定类型。但过度使用类型断言可能会降低代码的安全性,所以要谨慎使用。

九、泛型的高级应用

  1. 泛型约束 泛型约束用于限制泛型类型参数的范围。例如,我们定义一个函数,它接受一个数组,并返回数组中的第一个元素。但我们希望这个数组的元素具有 length 属性,我们可以使用泛型约束:
interface Lengthwise {
  length: number;
}
function getFirst<T extends Lengthwise>(arr: T[]): T | undefined {
  return arr.length > 0? arr[0] : undefined;
}
let strArr = ['a', 'b', 'c'];
let firstStr = getFirst(strArr); // firstStr 的类型为 string | undefined
let numArr = [1, 2, 3];
let firstNum = getFirst(numArr); // firstNum 的类型为 number | undefined

在上述代码中,T extends Lengthwise 表示泛型类型参数 T 必须具有 length 属性。 2. 泛型与条件类型的结合 泛型和条件类型结合可以实现非常灵活的类型变换。例如,我们可以实现一个类型,根据传入的布尔值泛型参数决定返回不同的类型:

type ConditionalType<T extends boolean> = T extends true? string : number;
type Result1 = ConditionalType<true>; // string
type Result2 = ConditionalType<false>; // number
  1. 泛型工具类型 TypeScript 提供了一些内置的泛型工具类型,如 PartialRequiredPickOmit 等。Partial<T> 可以将类型 T 的所有属性变为可选;Required<T> 则相反,将所有属性变为必选;Pick<T, K> 用于从类型 T 中选取属性 K 组成新类型;Omit<T, K> 用于从类型 T 中剔除属性 K 组成新类型。例如:
type User = {
  name: string;
  age: number;
  email: string;
};
type OptionalUser = Partial<User>;
type OnlyNameAndAge = Pick<User, 'name' | 'age'>;
type UserWithoutEmail = Omit<User, 'email'>;

这些泛型工具类型在实际项目中可以大大提高开发效率,减少重复的类型定义。

十、类型系统中的常见陷阱与解决方法

  1. 类型兼容性问题 有时候在 TypeScript 中会遇到类型兼容性的问题,比如一个函数期望接受某个类型的参数,但实际传入的类型看似相似却不兼容。例如,一个函数接受 { value: number } 类型的参数,而我们传入了 { value: number; extra: string } 类型的对象,这可能会导致类型错误。解决方法是确保传入的类型严格符合函数参数的类型定义,或者使用类型断言,但要谨慎使用。
  2. 类型推断不准确 在复杂的代码结构中,TypeScript 的类型推断可能会不准确。例如,在函数内部有复杂的逻辑和多个变量的情况下,类型推断可能无法正确推断出某些变量的类型。解决方法是显式声明变量的类型,或者将复杂的逻辑拆分成多个简单的函数,以便类型推断更容易正确工作。
  3. 循环引用问题 在定义类型时,如果出现循环引用,会导致编译错误。例如,两个类型相互引用:
// 错误示例
type A = {
  b: B;
};
type B = {
  a: A;
};

解决方法是尽量避免这种直接的循环引用,可以通过引入中间类型或者调整类型结构来解决。例如,可以将其中一个类型变为可选或者使用联合类型来打破循环。

通过深入理解和掌握这些 TypeScript 中的高级类型技巧,开发者能够编写出更加健壮、类型安全且可维护的代码,无论是在小型项目还是大型企业级应用中,都能充分发挥 TypeScript 的优势,提高开发效率和代码质量。