如何编写出更好的 TypeScript?

1,018 阅读6分钟

TypeScript 是一种强类型的 JavaScript 超集,为 JavaScript 添加了类型检查、接口和类等特性。它在开发大型应用程序时非常有用,因为它可以捕捉编译时的错误并提供更好的代码维护性。本文将介绍一些编写更好 TypeScript 代码的最佳实践,帮助您提升代码质量和开发效率。

1. 使用实用工具类型(Utility Types)

TypeScript 提供了一些内置的实用工具类型,如 PartialReadonlyPickOmit 等,帮助进行类型变换。

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

type PartialUser = Partial<User>; // 所有属性变为可选
type ReadonlyUser = Readonly<User>; // 所有属性变为只读
type PickUser = Pick<User, "id" | "name">; // 选择某些属性
type OmitUser = Omit<User, "email">; // 省略某些属性

这些高级用法使 TypeScript 成为强大且灵活的语言,能够处理各种复杂的类型系统需求。在实际开发中,合理运用这些特性可以显著提升代码的类型安全性和可读性。

2. 映射类型中的键重映射

在 TypeScript 中,映射类型(Mapped Types)是一种强大的特性,可以根据已有的类型创建新的类型。通过键重映射(Key Remapping),我们可以根据一定的规则修改映射类型中的键,这使得我们能够更加灵活地操作类型。

以下是一个使用键重映射的示例,展示如何从现有类型生成新类型,并在此过程中修改键名。

示例:将对象的键转换为小写

假设我们有一个接口 Person,表示一个人的信息。我们想要创建一个新类型,将所有属性名转换为小写。

// 原始类型定义
interface Person {
  FirstName: string;
  LastName: string;
  Age: number;
}

// 键重映射类型:将所有键名转换为小写
type LowercaseKeys<T> = {
  [K in keyof T as Lowercase<K & string>]: T[K];
};

// 应用键重映射类型
type LowercasePerson = LowercaseKeys<Person>;

// LowercasePerson 的结构将会是:
/*
type LowercasePerson = {
  firstname: string;
  lastname: string;
  age: number;
}
*/

// 示例使用
const person: LowercasePerson = {
  firstname: "John",
  lastname: "Doe",
  age: 30,
};

3. 使用功能重载

函数重载提供多个函数签名以更好地进行类型检查。

// 函数重载签名
function format(value: number): string;
function format(value: Date): string;
function format(value: string, uppercase: boolean): string;

// 函数实现
function format(value: number | Date | string, uppercase?: boolean): string {
  if (typeof value === "number") {
    return value.toFixed(2); // 格式化数字
  } else if (value instanceof Date) {
    return value.toISOString(); // 格式化日期
  } else if (typeof value === "string") {
    return uppercase ? value.toUpperCase() : value.toLowerCase(); // 格式化字符串
  }
  throw new Error("Invalid argument");
}

// 使用示例
console.log(format(1234.56));            // "1234.56"
console.log(format(new Date()));         // 例如 "2024-07-28T12:00:00.000Z"
console.log(format("Hello World", true)); // "HELLO WORLD"
console.log(format("Hello World", false));// "hello world"

4. 使用品牌类型来创建名义类型。

在 TypeScript 中,“品牌类型”(Branded Types)是一种创建名义类型(Nominal Typing)的方法,它通过给类型添加唯一的标识,使得两个具有相同结构的类型变得不互通。这样可以防止错误地将一个类型的值传递给另一个类似结构但语义不同的类型。

名义类型的一个常见用例是在表示不同实体的标识符时,即使它们都是简单的数值或字符串。

以下是一个使用品牌类型创建名义类型的示例:

// 定义品牌类型
type UserId = string & { readonly brand: unique symbol };
type OrderId = string & { readonly brand: unique symbol };

// 创建品牌类型的函数
function createUserId(id: string): UserId {
  return id as UserId;
}

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

// 使用品牌类型的函数
function getUserById(userId: UserId) {
  console.log(`Fetching user with ID: ${userId}`);
}

function getOrderById(orderId: OrderId) {
  console.log(`Fetching order with ID: ${orderId}`);
}

// 正确的用法
const myUserId = createUserId("user123");
const myOrderId = createOrderId("order456");

getUserById(myUserId); // 正确
getOrderById(myOrderId); // 正确

// 错误的用法:编译时类型检查会报错
// getUserById(myOrderId); // 错误: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
// getOrderById(myUserId); // 错误: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

5. 带有条件类型的模板文本类型

在 TypeScript 中,模板字面量类型(Template Literal Types)和条件类型(Conditional Types)可以结合使用,用于进行高级的字符串操作和类型推导。模板字面量类型允许根据字符串模板生成新类型,而条件类型则根据类型条件返回不同的类型。

以下是一个使用模板字面量类型和条件类型的示例,用于动态地生成请求方法(如 GET、POST 等)的类型:

// 定义请求方法
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

// 生成请求方法和URL的字符串组合类型
type RequestURL<M extends HttpMethod, U extends string> = `${M} /api/${U}`;

// 基于请求方法生成响应类型
type ResponseType<M extends HttpMethod> = 
  M extends 'GET' ? { data: string } :
  M extends 'POST' ? { success: boolean } :
  M extends 'PUT' ? { updated: boolean } :
  M extends 'DELETE' ? { deleted: boolean } :
  never;

// 定义一个通用的API请求函数类型
type ApiRequest<M extends HttpMethod, U extends string> = {
  method: M;
  url: RequestURL<M, U>;
  response: ResponseType<M>;
}

// 示例:定义具体的请求
type GetUserRequest = ApiRequest<'GET', 'users/123'>;
type CreateUserRequest = ApiRequest<'POST', 'users'>;
type UpdateUserRequest = ApiRequest<'PUT', 'users/123'>;
type DeleteUserRequest = ApiRequest<'DELETE', 'users/123'>;

// 使用示例
const getUser: GetUserRequest = {
  method: 'GET',
  url: 'GET /api/users/123',
  response: { data: "User data" }
};

const createUser: CreateUserRequest = {
  method: 'POST',
  url: 'POST /api/users',
  response: { success: true }
};

const updateUser: UpdateUserRequest = {
  method: 'PUT',
  url: 'PUT /api/users/123',
  response: { updated: true }
};

const deleteUser: DeleteUserRequest = {
  method: 'DELETE',
  url: 'DELETE /api/users/123',
  response: { deleted: true }
};

6. 使用 infer 推断类型变量

infer 是 TypeScript 中的一种高级类型操作符,用于在条件类型中推断类型变量。它常用于提取复杂类型中的某部分,或者从泛型类型中推断出具体类型。善用 infer 可以让我们编写更灵活和可复用的类型。

1. 提取函数的参数类型

我们可以使用 infer 提取函数的参数类型。例如,有一个函数类型,我们想提取它的参数类型:

type Func = (a: number, b: string) => void;

type Parameters<T> = T extends (...args: infer P) => any ? P : never;

type FuncParameters = Parameters<Func>; // [number, string]

在这个例子中,infer P 用于推断 Func 类型的参数类型 P,结果是 [number, string]

2. 提取函数的返回类型

类似地,我们可以使用 infer 提取函数的返回类型:

type Func = (a: number, b: string) => boolean;

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type FuncReturnType = ReturnType<Func>; // boolean

这里,infer R 用于推断 Func 的返回类型 R,结果是 boolean

3. 提取数组的元素类型

我们可以使用 infer 从数组类型中提取元素类型:

type ArrayType = [number, string, boolean];

type ElementType<T> = T extends (infer U)[] ? U : never;

type ArrayElementType = ElementType<ArrayType>; // number | string | boolean

在这个例子中,infer U 推断出数组中的元素类型 U

4. 提取 Promise 的返回类型

当我们使用 Promise 时,通常需要提取其中的值类型:

type PromiseType = Promise<number>;

type UnwrappedPromise<T> = T extends Promise<infer U> ? U : T;

type PromiseValueType = UnwrappedPromise<PromiseType>; // number

infer U 用于推断 Promise 的返回值类型 U

5. 提取元组的最后一个类型

我们可以使用 infer 提取元组的最后一个元素的类型:

type Tuple = [string, number, boolean];

type Last<T extends any[]> = T extends [...infer Rest, infer Last] ? Last : never;

type LastType = Last<Tuple>; // boolean

在这个例子中,infer Rest 用于推断元组的前部分,而 infer Last 用于推断最后一个元素的类型。