TypeScript模板字面量类型:从原理到实战,让字符串在编译期现形
以前写 TypeScript最让我头疼的一类 bug 是字符串拼错了鼠标悬停在类型上一看还是string编译器压根不吭声直到运行时事件没触发、接口请求路径 404才回头一个个查。后来用上了模板字面量类型Template Literal Types事情就变得不一样了——它能像写模板字符串一样在类型层面把字符串的形状约束住让字符串相关的错误在编译期直接现形。这个特性特别适合处理事件名、路由路径、CSS 变量名这类有规则、但手写联合类型又容易漏的场景。这篇就围绕模板字面量类型聊聊它的原理、实战用法以及我踩过的坑。1. 模板字面量类型类型层面的字符串拼接1.1 普通字符串字面量类型为什么不够用没接触类型体操之前我们处理一组固定字符串最直接的办法就是手写联合类型type OrderStatus pending | paid | shipped; type TaskState todo | doing | done;这种写法的痛点是只要某个字符串变长一点或者规则复杂一点手写联合类型就开始漏。比如一个事件系统里模块有几十个动作有好几个事件名是module:action这种格式如果要全量维护成一个联合类型type BusEvent | user:login | user:logout | user:add | cart:add | cart:remove // 后面还有几十个 ;这不是不能写而是每次新增一个模块或者动作你得记着来这里同步加一行。漏一次emit和on两边就悄悄对不上。更麻烦的是如果有一些动态 ID比如id:12345手写联合类型完全没法表达ID 部分是个数字字符串这层关系。字符串字面量类型擅长约束固定的那几条但不擅长描述字符串的生成规则。模板字面量类型补上的正是这个缺口。它允许你在类型里声明这段字符串是哪个前缀 哪段变量让类型系统帮你维护这些规则。1.2 基础语法和联合类型展开的机制模板字面量类型的语法和 ES6 的模板字符串几乎一样核心就是反引号加${}type CenterPoint ${number}-${number}; // 比如 1920-1080 这种形态 type LogPrefix [${INFO | WARN | ERROR}]; // 只可能是 [INFO] | [WARN] | [ERROR]一个很容易忽略的机制是当${}里放的是联合类型时TS 会做笛卡尔积展开。也就是说每个联合成员都会和其他联合成员组合一遍type EventName on${Click | Hover | Focus}; // type EventName onClick | onHover | onFocus这个行为很重要。它意味着你可以先定义一组基础字符串再通过拼接自动生成完整的联合类型而不是手写所有组合。打个比方手写联合类型好比在超市里一个个挑商品装进购物车模板字面量类型则是你给我一捆贴纸、一批盒子它自动帮你把所有组合都摆上货架。另外${}里不是只能放字符串能放的类型包括string | number | bigint | boolean | null | undefined。也就是说type Padding padding-${number}; // 合法 type Nil empty-${null | undefined}; // 合法会被展开成 empty-null | empty-undefined type Wrong value-${object}; // 报错object 不能放进模板字面量类型这一点经常有人踩坑。对象类型不能作为字符串片段进模板字面量类型毕竟对象没有自然的字符串化规则就算有类型系统也不负责调用toString()。2. 内置字符串操作类型给字符串片段做大小写归一2.1 Uppercase / Lowercase / Capitalize / Uncapitalize 的实战场景TS 4.1 提供了四个内置操作类型UppercaseT、LowercaseT、CapitalizeT、UncapitalizeT。它们可以直接作用在模板字面量类型的占位符上也可以单独用在泛型上。最常见的场景是给接口命名统一风格。比如你有一个组件的事件列表type CallbackNameK extends string on${CapitalizeK}; type EventMap { click: () void; change: (value: string) void; }; type EventKey keyof EventMap; // click | change type ExternalCallbackName CallbackNameEventKey; // onClick | onChange这样组件对外暴露的 props 名称就自动统一成onClick、onChange。如果哪天事件名改成doubleClickCapitalize会自动把它处理成onDoubleClick不用再人工改。再比如 Redux 的 action type团队规范要求全部大写下划线风格type ActionTypeK extends string FETCH_${UppercaseK}; type ProductTypes ActionTypesku | price; // FETCH_SKU | FETCH_PRICE用这种方式业务侧只要定义模块名或资源名action type 的全集就自动推导出来。很多人不知道Capitalize只首字母大写Uppercase是全大写两者的差别在拼接文件名、组件名时很容易踩到。2.2 大小写转换的底层限制我最初以为UppercaseT是用条件类型一个个字符转换实现的后来发现不是。看 TS 源码UppercaseT是编译器内置的 intrinsic 类型并没有开放同等的自定义能力。这就带来两个使用上的注意点。第一如果你传入string这种宽泛类型结果不会变成具体字面量而是还是stringtype S Uppercasestring; // string这是因为编译器无法对未知的字符串做静态转换只能保留宽泛类型。所以别指望在类型层把所有动态字符串变成大写字面量。第二当T是联合类型时这四个工具会对每个成员分别处理再合成一个联合类型这跟模板字面量类型组合展开的机制是配合的。习惯这个限制之后你就能判断什么时候适合用它们收窄那些已知字符串但大小写不统一的场景而不是试图转换任意运行时字符串。3. 实战事件总线、路由和 Action 的类型安全落地3.1 事件总线让 emit 和 on 的参数自动关联先说事件总线。我参与的一个中后台项目里模块间通信全走一个自定义事件总线。最开始事件名是各写各的有的写user:login有的写userLogin还有的写user_login。后来统一改成type Module user | cart | order; type Action login | logout | add | remove; type BusEvent ${Module}:${Action}; // user:login | user:logout | ... | order:remove这还没有完全解决载荷关联的问题。事件名对了但是emit(user:login, oops)如果载荷类型不对依然没人管。于是我把事件 map 和模板字面量类型结合interface BusPayloadMap { user:login: { uid: string }; cart:add: { sku: string; count: number }; } type KnownEvent keyof BusPayloadMap; declare function onK extends KnownEvent( name: K, handler: (payload: BusPayloadMap[K]) void ): void; declare function emitK extends KnownEvent( name: K, payload: BusPayloadMap[K] ): void;这样on和emit的事件名、载荷类型就绑定在一起了。模板字面量类型在这里的价值是当事件名需要按Module:Action的规则生成时你可以用BusEvent来约束BusPayloadMap的 key而不是手写一串重复字符串type ValidateEventMapT extends RecordBusEvent, unknown T; type ValidatedPayloadMap ValidateEventMapBusPayloadMap; // 如果 BusPayloadMap 里遗漏了某个事件名这一行会直接报错这种做法在团队里推广后最直接的收益是新增一个模块时先改Module联合类型然后编译器会立刻提示你BusPayloadMap还没补对应的事件想漏都难。3.2 路由路径解析infer 和模板字面量类型的配合路由是另一个适合用模板字面量类型的场景。过去我们经常写/user/ id拼完之后的类型还是string没法保证路径是合法的。用模板字面量类型至少可以把合法形状声明出来type UserRoute /user/${string}; const good: UserRoute /user/123; // ok const bad: UserRoute /order/123; // 报错更高级一点配合条件类型里的infer可以解析路径片段。比如实现一个拆分函数把/user/profile变成[user, profile]type SplitPathP extends string P extends ${infer Head}/${infer Tail} ? Head extends ? SplitPathTail : [Head, ...SplitPathTail] : [P]; type Result SplitPath/user/profile/settings; // [user, profile, settings]注意开头斜杠会被空字符串占一位所以要处理Head extends 的情况。这类解析类型在封装路由库的params推导时会非常有用。我还试过用infer N extends number从字符串里取出数字字面量。TS 4.8 之后支持这种写法type ToNumberS extends string S extends ${infer N extends number} ? N : never; type Id ToNumber1024; // type Id 1024当然真正的运行时代码只要Number(1024)一行就够了。类型层的转换更多是为了在泛型环境里把外部传入的1024自动规约为数字类型从而省去一次手写断言。3.3 接口继承与 Action 命名约束有人问我模板字面量类型和interface extends有什么关系。其实两者可以配合得很好。先定义一个泛型基接口然后用模板字面量类型生成最终的type字段interface BaseActionT extends string { type: T; payload: Recordstring, unknown; } type Resource user | product; type ListActions | BaseActionFETCH_${UppercaseResource}_LIST | BaseActionCREATE_${UppercaseResource}; type AppAction ListActions; // action.type 的取值会被约束为 // FETCH_USER_LIST | FETCH_PRODUCT_LIST | CREATE_USER | CREATE_PRODUCT如果你的项目里 action creator 返回的type字段必须保持统一格式这个写法比每个文件里各自写一个type: FETCH_USER_LIST更可靠。基接口负责公共结构模板字面量类型负责生成公共命名接口继承则允许你在不影响type约束的前提下扩展各自字段。interface CreateUserAction extends BaseActionCREATE_USER { payload: { name: string }; }这里CREATE_USER仍然满足CREATE_${UppercaseResource}的约束类型检查能通过同时又补充了自己特有的 payload 结构。4. 字符串长度、递归解析和声明文件里的高级用法4.1 类型层面的字符串长度到底能做什么有段时间我特别沉迷用模板字面量类型实现字符串长度。受限于类型系统的递归深度它并不能完全替代运行时String.prototype.length但在某些需要固定长度字符串的场景它能作为编译期约束。基础实现是每次拆一个字符剩下的继续递归type LengthOfString S extends string, Acc extends unknown[] [] S extends ${infer _Char}${infer Rest} ? LengthOfStringRest, [...Acc, unknown] : Acc[length]; type NameLen LengthOfStringtypescript; // type NameLen 10这个写法在 TS 4.1 之后可行但要注意类型递归有深度限制短字符串没问题特别长的字符串会让实例化栈溢出。我实测下来默认深度大约 50 层通过尾递归优化可以做到几百层但依然不适合作为通用工具。字符串长度这个需求运行时一行代码就能拿到准确值类型层做它有点炫技成分。真正有价值的不是测量长度而是利用递归去解析字符串结构。比如剥离前缀、判断是否包含某个占位符type HasPrefixS extends string, P extends string S extends ${P}${string} ? true : false; type A HasPrefixdata-user, data-; // true type B HasPrefixwrong, data-; // false这种模式在做协议解析、配置项校验时很好用。4.2 字符串逆序、排序和数字转换类型层能做的边界模板字面量类型配合递归还能实现很多看起来像运行时字符串 API 的操作。比如字符串逆序type ReverseS extends string S extends ${infer First}${infer Rest} ? ${ReverseRest}${First} : ; type Reversed Reversehello; // type Reversed olleh再配合一个简单的冒泡排序思路甚至可以给字符串类型排序。但这些操作的计算量会随字符串长度指数增长除非是为了解决特定类型兼容问题否则我不建议在业务代码里写。真正值得记住的是模板字面量类型提供的是类型层面的模式匹配。你不需要用它重写所有运行时字符串方法。我在项目里最多用到infer配合extends number做数字解析以及配合Uppercase做枚举名归一。其余的多数是面试题或者类型库的玩具。4.3 在 .d.ts 声明文件里怎么用模板字面量类型热词里有人问 types 文件夹的声明文件如何使用这里顺便讲一下在.d.ts里用模板字面量类型。声明文件通常用来给全局变量、第三方模块补充类型。模板字面量类型可以用在declare global里给全局事件 map 做更严格的定义declare global { interface WindowEventMap { app:toast: CustomEvent{ message: string; type: success | error }; } } export {};如果你在维护一个 SDK 的声明文件可以用模板字面量类型暴露模式declare function onE extends string(event: app:${E}, handler: (payload: unknown) void): void;这样使用方调用on(app:login)是合法的调用on(other:login)就会编译报错。全局声明文件里要注意避免写太复杂的导出结构否则容易产生循环引用和命名冲突。我通常只放type和interface简单直接。5. 避坑手册联合爆炸、泛型边界和错误定位5.1 联合类型组合爆炸是最大的性能隐患模板字面量类型虽然方便但它有一个很现实的问题联合类型的笛卡尔积展开可能会非常大。type A a1 | a2 | ... | a100; // 100 个 type B b1 | b2 | ... | b100; // 100 个 type C ${A}_${B}; // 10000 个组合如果 A 和 B 都是几十上百的枚举值生成后的联合类型会占用大量编译器资源编辑器可能卡顿甚至直接报 Type instantiation is excessively deep or possibly infinite。我在一个数据字典模块里吃过亏字典类型有 50 个业务类型有 80 个模板字面量拼接后生成了 4000 种组合保存文件要等好几秒。避免办法不要把所有枚举都放到模板字面量里做全组合。改成用条件类型按需判断或者保留一个宽泛string兜底。模板字面量类型适合组合数量可控的场景组合数量超过几百之后就要开始警惕了。5.2 泛型参数忘记 extends string 的报错和理解模板字面量类型对占位符类型有约束。写泛型工具时经常会这样type PrefixT prefix-${T}; // 如果 T 是对象会报错正确写法是给泛型加约束type PrefixT extends string | number | boolean | null | undefined prefix-${T};这个extends约束不只是在入口处拦一下它也在告诉阅读你类型代码的人这个工具能接受什么输入。我建议在公共类型封装时把约束写全否则别人传一个Recordstring, unknown进来报错信息会指向模板字面量类型那一行而不是调用处排查起来很费劲。5.3 如何快速定位类型报错模板字面量类型导致的报错信息往往又长又绕特别是联合类型成千上万时。我的调试经验是先用一个具体的错误字符串测试工具类型比如type T SomeMagicTypebad-value然后故意跟never对比观察是预期还是不预期。使用// ts-expect-error标记这一行就应该报错如果没有报错就说明类型工具不够严格这是个有效的自测手段。遇到工具类型内部报错时不要急着全文排查先一步步拆小比如把${infer Head}${infer Rest}的拆解结果单独显示出来确认每层的输入输出是否符合预期。这种小步验证的方式比反复悬停鼠标看 TS 提示高效得多。5.4 团队协作时的可维护性问题类型代码最终也是代码要给别人读。模板字面量类型写起来很爽读起来可能让人一头雾水。我在团队里定的规则是核心工具类型必须写注释说明它生成的目标字符串格式。一个文件里最多允许两层模板字面量组合再复杂就拆成小函数类型。不在.d.ts全局声明里做大量类型体操避免影响所有文件编译速度。用这些约束之后模板字面量类型不仅没有成为团队负担反而成了消灭字符串类型 bug 的利器。6. 什么时候上模板字面量类型什么时候别硬上我现在的判断标准很简单字符串如果是一组有限且规律的值就用模板字面量类型把它表达出来如果字符串是自由文本日志、普通文案、用户名那就老老实实用string别给自己找麻烦。适合用模板字面量类型的场景我很明确事件名module:action路由路径/user/${id}CSS 变量名--${string}Redux action 类型组件 callback 属性名。这些场景的规律性强而且一旦用上扩展枚举时编译器会帮你兜底。不适合的场景我也踩过强行把嵌套很深的业务字符串做成模板字面量类型导致每次类型推导要跑好几轮递归还试过用类型体操校验用户输入必须是多少位手机号结果编辑器和运行时各算各的纯粹是自找麻烦。最后分享一个我最近常用的调试技巧遇到模板字面量类型相关报错我先在调用处故意写一个错误字符串比如emit(user:deleete, ...)然后看编辑器的自动补全和报错提示里列出了哪些合法值。这个办法比反复翻类型定义直观得多。模板字面量类型这东西写的时候像拼字符串调的时候像在编译器身上装了一个纠错雷达一旦用顺了你就很难再回到到处手写string和靠人肉维护联合类型的日子了。