TypeScript 模板字面量类型详解

1. 概述

模板字面量类型允许你在 TypeScript 的类型系统中直接使用反引号语法。这样你就能在编译时拼接、转换和解析字符串类型。本文通过具体示例介绍每项功能:合并联合类型、在映射类型中重命名键、从路由提取参数、编写类型安全的事件发射器。阅读完毕,你将了解何时应该使用此技术,以及如何测试它,还有哪些地方容易导致编译器变慢或产生难以理解的错误。

文中所有示例均使用 TypeScript 5.x 和 strict: true。

2. 基本语法

模板字面量类型看起来就像运行时的模板字符串。区别在于插值部分 ${...} 包含的是类型,而不是值:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

类型 OrderId 接受所有以 ORD- 开头且后面跟数字字符串的字符串。你可以插值 string、number、bigint、boolean、null、undefined 及其对应的字面量类型。

一个常见的例子是带单位的 CSS 值:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // Error: 缺少 "px"

编译器现在能捕获忘记单位的错误。之前这种错误只有在打开浏览器时才会暴露。

3. 与联合类型结合:笛卡尔积

当插值部分是联合类型时,TypeScript 分发模板至每个成员。如果有多个联合类型,结果是所有可能的组合:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

在编辑器中悬停在 ButtonClass 上,你会看到:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

这里有 3 × 2 = 6 个成员自动生成。当向 Size 添加 "xl" 时,ButtonClass 会自动更新。你不需要修改其他任何地方。

注意: 组合数呈指数增长。如果结果联合类型超过 100,000 个成员,TypeScript 会报错 “Expression produces a union type that is too complex to represent”。不要将多个大型联合类型组合在一起,例如 1,000 个产品代码的联合类型与 200 个国家代码的联合类型。

4. 内置字符串转换类型

TypeScript 提供了四个内置类型来转换大小写:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

它们内置在编译器中,不是用 TypeScript 编写的。这四种类型通常与模板字面量一起使用来按约定创建名称,例如 on${Capitalize<K>} 或 ${Uppercase<K>}_ENV。

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. 在映射类型中重命名键

这是模板字面量类型发挥最大作用的地方。映射类型中的 as 子句允许你在创建新类型时重命名每个键。

5.1. 从接口生成 getter

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

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

type CustomerGetters = Getters<Customer>;

结果:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

逐部分解释:

  • keyof T & string 过滤掉 number 和 symbol 类型的键。原因是 Capitalize 只接受 string。如果省略 & string,你会得到错误 “Type ‘K’ does not satisfy the constraint ‘string’”。
  • as \get${Capitalize}`将name改为getName`。
  • () => T[K] 保留原始属性的正确返回类型。

实现这种类型的类如果缺少 getter 或返回错误类型,编译器会立即捕获错误:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. 通过返回 never 过滤键

如果 as 中的表达式产生 never,该键将从结果中排除。你可以将其与模板字面量结合,按前缀选择键:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refund 不匹配 charge${string} 模式,因此被排除。

6. 使用 infer 解析字符串

模板字面量不仅用于连接字符串。你还可以用它在条件类型中解析字符串,结合 infer。

6.1. 简单的字符串分解

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

当模式匹配时,TypeScript 将剩余部分分配给 Rest。

6.2. 从路由提取参数

这是模板字面量类型最常见的实际应用。从字符串 "/users/:userId/orders/:orderId" 中,我们想要得到类型 "userId" | "orderId":

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

它的运行方式:

  1. 第一个分支匹配 "/users/" + ":" + "userId" + "/" + "orders/:orderId"。每个占位符匹配其后面字面量字符的第一次出现。因此 Param 是 "userId"。
  2. 递归类型为 "/orders/:orderId"。这次参数后面没有 /,所以第一个分支失败。
  3. 第二个分支匹配并返回 "orderId"。
  4. 如果字符串没有 :,结果是 never。

现在将其用于构建 URL 的函数:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// Error: 对象字面量只能指定已知属性,'id' 不存在...

输出:

/users/42/orders/ORD-7

通用参数 Path 从第一个参数推断为字面量类型。这样 params 对象就会被精确检查:缺少键、多余键或名称拼写错误都会被报告为错误。需要在函数体中将类型强制转换为 Record<string, string>。TypeScript 无法证明在运行时从正则表达式中提取的 key 属于 ExtractParams<Path>。

7. 综合示例:类型安全的事件发射器

将上述技术组合在一起,我们有一个用于订单域的发射器:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

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

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error

输出:

Created ORD-1: 250000
Shipped via GHN

你能得到什么:

  • 处理器名称 onCreated、onShipped、onCancelled 自动从 OrderEvents 生成。
  • 每个处理器中的参数 p 被推断为正确的有效负载类型,无需声明。
  • emit("shipped", ...) 强制有效负载具有 carrier。

在 emit 中有两个 as 是告诉编译器它无法自己推断的内容。在运行时构建的字符串不携带类型信息。请将这样的类型转换保持在小范围内,放在实现部分。公开 API 仍然完全安全。

8. 使用 Vitest 测试类型

类型层的逻辑也需要测试。否则,一次重构可能会将 ExtractParams 变成 string,而没人会发现。安装 Vitest:

npm install -D vitest typescript

为类型行为和运行时行为编写测试:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("正确提取参数名", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("没有参数时返回 never", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("替换参数并编码值", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("拒绝错误的键", () => {
    // @ts-expect-error - 'id' 不是路由的参数
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

运行:

npx tsc --noEmit && npx vitest run

expectTypeOf 在运行时是无操作的。它的错误仅在类型检查时出现,因此 tsc --noEmit 步骤(或 vitest --typecheck)是必需的。@ts-expect-error 指令也是一个断言。如果下一行没有类型错误,tsc 会报告 “Unused ‘@ts-expect-error’ directive”。

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

注意: 模板字面量类型仅在编译时存在。来自 API、表单或 process.env 的数据完全不会被检查。对于外部数据,你仍需要在运行时通过正则表达式或模式库进行验证。

一些具体的陷阱:

  • ${number} 比你想象的更宽松。 "1e3px" 或 "0.5px" 都对 `${number}px` 有效。如果需要“正整数”,模板字面量无法表示。请在运行时检查。
  • 联合类型爆炸。 如第 3 部分所述,将多个大型联合类型组合会使编译器显著变慢,甚至报告错误过于复杂。
  • 深层递归。 像 ExtractParams 这样的递归类型有深度限制。对于很长的字符串,你会遇到 “Type instantiation is excessively deep and possibly infinite”。通常的路由不会触及此限制,但通用字符串解析器可能会。
  • 错误消息难以阅读。 当类型复杂时,错误可能会长达数十行。如果同事每天都要阅读这样的错误,代价可能超过收益。
  • 缺少 & string。 keyof T 可能包含 number | symbol,而 Capitalize 会拒绝它们。
  • 滥用。 如果值集很小且固定,直接写字面量联合。写 type Size = "sm" | "md" 比任何自动生成的类型都更容易阅读。

应该在有单一源数据(接口、事件对象、路由字符串)且希望从中推断其他类型时使用。不应该仅为了展示类型级别的技能而使用。

10. 结论

模板字面量类型使字符串成为 TypeScript 类型系统中的一等公民。本文已介绍了主要技术:

  • 连接字符串和通过联合类型分发;
  • 使用 Uppercase、Capitalize 等进行大小写转换;
  • 使用 as 在映射类型中重命名和过滤键;
  • 使用 infer 解析字符串,包括递归方式。

正确应用在路由参数、事件名或 getter 等地方,你可以消除整个类别的命名拼写错误,这些错误以前只能在运行时发现。记住用 expectTypeOf 和 @ts-expect-error 测试类型。留意联合类型的复杂性和递归深度。并始终验证来自程序外部的数据。

typescripttemplate literal typestype systemgenericsvitest