前端开发··1 阅读·预计 15 分钟

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 段也不意味着应该用类型做路径拼接。工程化建议

  1. 路径段 ≤ 10:模板字面量类型优于运行时校验,开发体验好
  2. 路径段 10-20:考虑尾递归优化,但添加 CI 类型检查时长告警
  3. 路径段 > 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 个

总结

模板字面量类型的价值不在字符串拼接能力本身,而在于它能将运行时字符串约定提升到类型层面进行验证。用好它的关键不是写得更「花」,而是:

  1. 分层窄化:不让单一类型承担全量组合
  2. 尾递归优化:延长可用深度,同时设定使用上限
  3. 类型描述运行时,而非运行时迁就类型:模板字面量是用来匹配既成事实的,不是用来发明新约定的

当你下次想写 type CSS = `${" margin "|" padding "}-${Direction}` 时,先问自己一句:运行时真的需要所有组合吗?

0 评论

评论区

登录 后参与评论