TypeScript 模板字面量类型工程化:从字符串拼接到类型级 DSL 的演进
引子:一个看似合理的类型声明
假设你正在构建一个事件系统,事件名格式为 domain:action:
// ❌ 直觉写法——结果出乎意料
type Domain = "user" | "order" | "payment";
type Action = "create" | "update" | "delete";
type EventName = `${Domain}:${Action}`;
// ^? "user:create" | "user:update" | "user:delete"
// | "order:create" | "order:update" | "order:delete"
// | "payment:create" | "payment:update" | "payment:delete"
3 × 3 = 9 个联合成员,看起来还好。但如果领域增加到 8 个、操作增加到 6 个呢?8 × 6 = 48 个成员。再加一层路径段${Domain}:${SubDomain}:${Action},组合数爆炸到 8 × 5 × 6 = 240。TypeScript 联合类型在成员超过一定阈值(约 10 万)后会触发编译性能退化。
本文从三个真实工程场景出发,探讨模板字面量类型的正确打开方式。
场景一:事件命名系统——避免笛卡尔积爆炸
问题:联合成员膨胀
事件系统的层级越多,模板字面量生成的字面量联合越庞大。当我们需要按领域分发处理器时,全量联合不仅是编译负担,更让类型提示失去可读性:
// ❌ 类型提示变成一堵墙
type AllEvents =
| "user:profile:create" | "user:profile:update" | "user:profile:delete"
| "user:auth:login" | "user:auth:logout" | "user:auth:refresh"
| "order:payment:create" | "order:payment:cancel" | "order:payment:refund"
| "order:ship:create" | "order:ship:update" | "order:ship:track"
| "inventory:stock:check" | "inventory:stock:reserve" | "inventory:stock:release"
// ... 50+ more lines in tooltip
方案:分层类型约束 + 泛型窄化
不在一层生成全部事件,而是用泛型按需约束:
// ✅ 分层定义,按领域窄化
type Domain = "user" | "order" | "inventory";
type UserSubDomain = "profile" | "auth";
type OrderSubDomain = "payment" | "ship";
type InventorySubDomain = "stock";
type Action = "create" | "update" | "delete" | "login" | "logout";
// 用对象类型做映射表约束
type DomainMap = {
user: UserSubDomain;
order: OrderSubDomain;
inventory: InventorySubDomain;
};
// 事件处理器只接受指定领域的事件
type DomainEvent<D extends Domain> = `${D}:${DomainMap[D]}:${Action}`;
// 使用时编译器自动窄化
declare function onEvent<D extends Domain>(
domain: D,
handler: (event: DomainEvent<D>) => void
): void;
onEvent("user", (event) => {
// event 类型自动窄化为 "user:profile:create" | "user:profile:update" | ...
// 而非整个事件宇宙
});
onEvent("order", (event) => {
// ❌ "user:profile:create" 不在类型中
});
关键思路:用映射类型限制模板字面量的输入域,让编译器在调用点做「产品类型」窄化,而非预先生成全量笛卡尔积。
场景二:递归类型深度上限——什么时候不该用模板字面量
问题:递归模板字面量的硬限制
TypeScript 的递归类型深度限制约为 50 层。当你试图用模板字面量做路径解析时,很容易撞墙:
// ❌ 递归到第 10 层就会触发「类型实例化过深」
type JoinPath<T extends string[], Sep extends string = "/"> =
T extends [infer First extends string, ...infer Rest extends string[]]
? Rest extends []
? First
: `${First}${Sep}${JoinPath<Rest, Sep>}`
: never;
type Deep = JoinPath<["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]>;
// ^? 正常
type TooDeep = JoinPath<["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t"]>;
// ^? 报错:Type instantiation is excessively deep and possibly infinite
方案:尾递归优化 + 累加器模式
// ✅ 尾递归累加器:编译器可以复用类型栈帧
type JoinPathTR<
T extends string[],
Sep extends string = "/",
Acc extends string = ""
> = T extends [infer First extends string, ...infer Rest extends string[]]
? JoinPathTR<Rest, Sep, Acc extends "" ? First : `${Acc}${Sep}${First}`>
: Acc;
// 64 个路径段也能编译
type Long = JoinPathTR<[
"a","b","c","d","e","f","g","h","i","j",
"k","l","m","n","o","p","q","r","s","t",
"u","v","w","x","y","z","aa","ab","ac","ad",
"ae","af","ag","ah","ai","aj","ak","al","am","an",
"ao","ap","aq","ar","as","at","au","av","aw","ax",
"ay","az","ba","bb","bc","bd","be","bf","bg","bh",
"bi","bj","bk","bl"
]>; // ✅ 编译通过
但即使尾递归能延展深度,64 段也不意味着应该用类型做路径拼接。工程化建议:
- 路径段 ≤ 10:模板字面量类型优于运行时校验,开发体验好
- 路径段 10-20:考虑尾递归优化,但添加 CI 类型检查时长告警
- 路径段 > 20:放弃类型级拼接,切换到
string+ 运行时zod校验
场景三:类型级 DSL——用模板字面量构建类型安全的状态机
这是模板字面量类型最值得投入的场景:用类型描述领域规则,让编译器执行约束。
背景:工作流状态转换
一个审批流程:draft → submitted → (approved | rejected),状态转换有业务规则:
// ❌ 用枚举 + switch,规则分散在代码和注释中
enum Status { Draft, Submitted, Approved, Rejected }
function transition(from: Status, to: Status) {
// 运行时校验——但不看源码谁知道允许哪些转换?
if (from === Status.Draft && to !== Status.Submitted) throw Error();
if (from === Status.Submitted && (to !== Status.Approved && to !== Status.Rejected)) throw Error();
// ...
}
方案:用模板字面量编码状态转换图
// ✅ 状态转换规则编译时可见
type StateTransitions = {
draft: "submitted";
submitted: "approved" | "rejected";
approved: "archived";
rejected: "draft"; // 驳回回到草稿
};
type Transition<From extends keyof StateTransitions> =
`${From}→${StateTransitions[From]}`;
type ValidTransition = {
[K in keyof StateTransitions]: Transition<K>;
}[keyof StateTransitions];
// ^? "draft→submitted" | "submitted→approved" | "submitted→rejected"
// | "approved→archived" | "rejected→draft"
// 业务函数——非法转换在编译期就被拦截
declare function executeTransition<T extends ValidTransition>(t: T): void;
executeTransition("draft→submitted"); // ✅
executeTransition("submitted→approved"); // ✅
executeTransition("draft→approved"); // ❌ TS2345
executeTransition("approved→draft"); // ❌ TS2345
更进一步,我们可以把状态机 DSL 做成可扩展的:
// ✅ 泛型状态机工厂——换一组建模另一套业务
type StateMachine<States extends Record<string, string | string[]>> = {
transition: {
[K in keyof States & string]: States[K] extends string
? `${K}→${States[K]}`
: States[K] extends string[]
? `${K}→${States[K][number]}`
: never;
}[keyof States & string];
};
// 审批流用这个
type ApprovalSM = StateMachine<{
draft: "submitted";
submitted: ["approved", "rejected"];
approved: "archived";
rejected: "draft";
}>;
// 支付流也用这个
type PaymentSM = StateMachine<{
pending: ["processing", "failed"];
processing: ["completed", "failed"];
}>;
// PaymentSM = "pending→processing" | "pending→failed"
// | "processing→completed" | "processing→failed"
工程化落地的三条红线
结合上述场景,总结三条在项目中引入模板字面量类型的约束规则:
红线一:笛卡尔积必须有泛型窄化出口
// ❌ 底线突破
type Big = `${A}${B}${C}${D}`; // A×B×C×D 可能爆炸
// ✅ 必须提供窄化入口
type Narrowed<D extends Domain> = `${D}:${ActionMap[D]}`; // 用泛型限缩输入域
红线二:递归类型必须配 CI 深度检查
在 CI 中增加 tsc --noEmit 耗时的监控。若编译时间突增 20% 且近期引入新类型体操,优先排查递归模板字面量。
红线三:模板字面量做「类型约束」而非「类型生成」
模板字面量类型的最佳实践是描述已有的运行时字符串格式,而非为尚未存在的字符串生成类型。你应该先有运行时代码,再用类型去描述它——而不是反过来。
// ✅ 描述已有约定:API 路径格式
type ApiPath = `/api/v${number}/${string}`;
const path: ApiPath = `/api/v2/users`; // 运行时实际使用的值
// ❌ 凭空创造类型宇宙:然后纠结怎么实现运行时
type AllPaths = `${Method} /api/${Version}/${Resource}/${Id}`;
// 2 × 3 × 20 × 5 = 600 个路径,但运行时只需要其中 15 个
总结
模板字面量类型的价值不在字符串拼接能力本身,而在于它能将运行时字符串约定提升到类型层面进行验证。用好它的关键不是写得更「花」,而是:
- 分层窄化:不让单一类型承担全量组合
- 尾递归优化:延长可用深度,同时设定使用上限
- 类型描述运行时,而非运行时迁就类型:模板字面量是用来匹配既成事实的,不是用来发明新约定的
当你下次想写 type CSS = `${" margin "|" padding "}-${Direction}` 时,先问自己一句:运行时真的需要所有组合吗?
评论区
登录 后参与评论