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"
它的运行方式:
- 第一个分支匹配
"/users/" + ":" + "userId" + "/" + "orders/:orderId"。每个占位符匹配其后面字面量字符的第一次出现。因此Param是"userId"。 - 递归类型为
"/orders/:orderId"。这次参数后面没有/,所以第一个分支失败。 - 第二个分支匹配并返回
"orderId"。 - 如果字符串没有
:,结果是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 测试类型。留意联合类型的复杂性和递归深度。并始终验证来自程序外部的数据。