TypeScript 中的映射类型和条件类型

1. 概览

在你的 TypeScript 项目中,经常会编写很多相似的类型:Order、然后 ReadonlyOrder、OrderUpdate、OrderGetters 等等。逐个手写这些类型既费工夫,又容易在 Order 改变时出现不一致。

映射类型和条件类型正好能解决这个问题。它们让你能够从现有类型生成新类型。读完这篇文章,你就能自己编写诸如 Partial、PickByType、UnwrapPromise 这样的工具类型。你还会知道如何对它们进行测试,以及何时应该停止使用它们。

2. 准备

你需要 TypeScript 5.x 并启用 strict。本文使用 Vitest 来测试类型:

npm install -D typescript vitest
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true
  }
}

strict: true 同时启用了 strictNullChecks。本文中的一些结果,例如 undefined 是否与 string 匹配,取决于这个标志。

示例使用以下共享类型:

export interface Order {
  id: string;
  customerId: string;
  total: number;
  paid: boolean;
  note?: string;
}

3. 映射类型

3.1. 基本语法

映射类型遍历一组 key 并为每个 key 创建一个属性:

type ReadonlyOrder = {
  readonly [K in keyof Order]: Order[K];
};

const order: ReadonlyOrder = {
  id: "o-1",
  customerId: "c-1",
  total: 250_000,
  paid: false,
};

order.paid = true; // 编译错误

解释:

  • keyof Order 是联合类型 "id" | "customerId" | "total" | "paid" | "note"。
  • [K in ...] 遍历该联合中的每个 key。
  • Order[K] 是索引访问类型。它获取属性 K 的类型。
  • 形式为 [K in keyof T] 的映射类型被称为同态的。这种形式保留了原始的修饰符,所以 note 仍然是可选的。

最后一行会报错:

error TS2540: Cannot assign to 'paid' because it is a read-only property.

上面的例子仍然硬编码到 Order。为了对所有类型重复使用,你添加一个泛型参数:

type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };

type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }

这正是标准库定义 Readonly<T> 和 Partial<T> 的方式。

3.2. 添加和移除修饰符

你可以在 readonly 和 ? 前面放置 + 或 -。+ 是默认的,通常被省略。- 用来移除修饰符:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };

type CompleteOrder = Concrete<Order>;

const draft: CompleteOrder = {
  id: "o-2",
  customerId: "c-9",
  total: 99_000,
  paid: true,
  note: "工作时间配送",
};

通过 Concrete<Order>,属性 note 变成了必需。它的类型是 string,不再是 string | undefined。标准库有内置的 Required<T> 做正是这件事。

3.3. 使用 as 重命名 key

从 TypeScript 4.1 开始,你可以用 as 子句重命名 key。这种方式通常与模板字面量类型搭配使用:

interface Customer {
  id: string;
  name: string;
  email: string;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;
// {
//   getId: () => string;
//   getName: () => string;
//   getEmail: () => string;
// }

string & K 部分是强制的。keyof T 可能包含 number 和 symbol,而 Capitalize 只接受 string。类型交集会排除非字符串的 key。

你也可以用 as 来过滤 key。如果表达式返回 never,那个 key 会从结果中排除:

type WithoutId<T> = {
  [K in keyof T as Exclude<K, "id">]: T[K];
};

type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }

这里的 Exclude 是一个条件类型。这是下一部分的主题。

4. 条件类型

4.1. extends ? : 语法

条件类型的工作方式就像三元运算符,但是在类型级别上:

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // true
type B = IsString<42>;      // false

T extends string 意思是“T 能否赋值给 string”。这不是类的继承关系。

4.2. 分布性

当 T 是一个裸露的泛型参数且你传入一个联合,TypeScript 会对联合的每个成员应用条件:

type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;

type C = ToArray<string | number>;        // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]

两个结果完全不同。把 T 包在元组 [T] 中会关闭分布性。

分布性是 Exclude 和 Extract 的基础:

type MyExclude<T, U> = T extends U ? never : T;

type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"

TypeScript 考虑每个成员。"cancelled" 变成 never,而 never 自动从联合中消失。

注意: 一个分布式条件类型接收 never 时会返回 never,因为 never 是空联合。例如 ToArray<never> 是 never,不是 never[]。如果你需要检测 never,使用包装元组的形式:[T] extends [never] ? ... : ...。

4.3. 用 infer 提取类型

infer 让你在条件的真分支中“捕获”类型的一部分:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;

async function fetchOrder(id: string): Promise<Order> {
  return { id, customerId: "c-1", total: 0, paid: false };
}

type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>;   // Order
type Item = ElementOf<Order[]>;                    // Order

每部分的含义:

  • Promise<infer U> 声明类型变量 U 并让 TypeScript 自动推断其值。
  • 如果匹配,真分支使用 U。如果不匹配,UnwrapPromise 返回原始的 T。
  • ReturnType 和 Parameters 在标准库中也是用 infer 写的。

在实际代码中,你应该使用内置的 Awaited<T>。它可以处理嵌套的 Promise,比如 Promise<Promise<Order>>。

5. 结合两种技术

5.1. 按值的类型选择属性

把条件类型放到 as 子句中,你就可以按值的类型过滤 key:

type PickByType<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }

注意 note 没有出现在 OrderStrings 中。Order["note"] 的类型是 string | undefined,当启用 strictNullChecks 时,这个类型不能赋值给 string。这里 T[K] 不是裸露参数,所以条件不分布。联合被视为一个整体。

5.2. 类型化的事件处理程序

这是一个更接近真实代码的例子。你有一个订单事件表,想为处理程序生成类型:

type OrderEvents = {
  created: { orderId: string; total: number };
  paid: { orderId: string; paidAt: Date };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};

const handlers: EventHandlers<OrderEvents> = {
  onCreated: (p) => console.log(`创建订单 ${p.orderId}: ${p.total}`),
  onPaid: (p) => console.log(`订单在 ${p.paidAt.toISOString()} 时已支付`),
  onCancelled: (p) => console.log(`订单被取消,原因:${p.reason}`),
};

handlers.onCreated({ orderId: "o-1", total: 250_000 });
创建订单 o-1: 250000

每个处理程序中的参数 p 被正确推断为其类型,所以你不必重新声明。当你添加事件 refunded 到 OrderEvents 时,TypeScript 会立即报错,因为 handlers 缺少 onRefunded。

5.3. 为生成的类型实现运行时

映射类型只在编译时存在。要有真实的对象,你仍需自己编写逻辑:

export function toGetters<T extends object>(obj: T): Getters<T> {
  const result: Record<string, () => unknown> = {};
  for (const key of Object.keys(obj) as (keyof T & string)[]) {
    const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
    result[name] = () => obj[key];
  }
  return result as unknown as Getters<T>;
}

为什么需要类型断言?TypeScript 无法证明循环的运行时实际创建了正确形状的 Getters<T>。as unknown as 的强制转换是你自己承诺了这一点。因此,这段代码必须有测试。

6. 用 Vitest 测试类型

Vitest 有 expectTypeOf 来检查类型,配合常规的运行时断言:

import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";

describe("utility types", () => {
  it("PickByType 只保留类型匹配的属性", () => {
    expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
    expectTypeOf<PickByType<Order, string>>()
      .toEqualTypeOf<{ id: string; customerId: string }>();
  });

  it("UnwrapPromise 解开一层 Promise", () => {
    expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  it("toGetters 返回正确值的 getter", () => {
    const getters = toGetters({ id: "c-1", name: "An", email: "an@example.com" });

    expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
    expect(getters.getName()).toBe("An");
    expect(getters.getEmail()).toBe("an@example.com");

    // @ts-expect-error getter 不存在应报编译错误
    getters.getPhone;
  });
});

运行带类型检查的测试:

npx vitest run --typecheck

注意以下要点:

  • 在运行时,expectTypeOf 什么都不做。实际的检查发生在编译器分析文件时。因此你需要 --typecheck 标志,或在 CI 中额外运行 npx tsc --noEmit。
  • // @ts-expect-error 用于反面测试。如果下面的行不再报错,比如有人让类型变得更宽松了,这个 directive 本身会导致编译错误。
  • toGetters 的运行时测试帮助你捕捉运行时逻辑偏离所承诺类型的情况。

7. 常见错误和何时不应该使用

忘记在模板字面量中使用 string & K。 写 `get${Capitalize<K>}` 会报错,因为 K 可能是 number | symbol。

不注意分布性。 你写 IsString<string | number> 希望得到 false,但结果是 boolean,即 true | false。如果想把整个联合作为一个整体检查,包装在元组中。

混淆 extends 和 any 的用法。 any 使条件返回两个分支都取合并。例如 IsString<any> 是 boolean。

在非同态 map 中丢失修饰符。 映射类型 [K in "id" | "total"]: Order[K] 不会保留 Order 中 readonly 或 ? 的修饰符。只有 [K in keyof T] 才会保留。如果需要选择一些 key 但仍保留修饰符,使用 Pick。

递归过深。 像 DeepPartial<T> 这样的类型在嵌套对象上递归应用可能导致错误 Type instantiation is excessively deep and possibly infinite。它也会让编辑器明显变慢。另外,Date、Map 和数组也是 object,所以条件 T[K] extends object 常常会误捕。

注意: 不要仅为了“看起来聪明”就使用映射类型和条件类型。如果一个类型只在一个地方使用且很少改变,手写的 interface 会容易理解得多。3–4 层嵌套类型的错误信息对同事来说很难理解。优先使用现成的工具类型(Partial、Pick、Omit、Record、Exclude、ReturnType、Awaited),再考虑自己写。

相信类型能保护运行时。 映射类型不检查来自 API 的数据。如果 JSON 缺少字段,Getters<Customer> 在编译时仍然“正确”但运行时出错。对于来自外部的数据,需要额外的运行时验证步骤。

8. 结论

这篇文章介绍了在 TypeScript 中生成类型的两个核心工具:

  • 映射类型,用 [K in keyof T],允许遍历 key、添加或移除 readonly/?、用 as 重命名和过滤 key。
  • 条件类型,用 T extends U ? X : Y,允许在类型级别分支。它们在联合上分布,并用 infer 提取子类型。

结合两种技术时,你可以写出 PickByType 或 EventHandlers 这样的类型。这些类型会在原始类型改变时自动更新。始终用 expectTypeOf 和 @ts-expect-error 附带类型测试。在 CI 中运行 --typecheck。保持这些类型足够简单,让其他人能读懂。

typescriptmapped typesconditional typesgenericstype system