☰
TypeScript模板字面量类型:从原理到实战,让字符串在编译期现形
2026/10/9 4:17:49 网站建设 项目流程

以前写 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 提供了四个内置操作类型:Uppercase<T>、Lowercase<T>、Capitalize<T>、Uncapitalize<T>。它们可以直接作用在模板字面量类型的占位符上,也可以单独用在泛型上。

最常见的场景是给接口命名统一风格。比如你有一个组件的事件列表:

type CallbackName<K extends string> = `on${Capitalize<K>}`; type EventMap = { click: () => void; change: (value: string) => void; }; type EventKey = keyof EventMap; // "click" | "change" type ExternalCallbackName = CallbackName<EventKey>; // "onClick" | "onChange"

这样组件对外暴露的 props 名称就自动统一成onClick、onChange。如果哪天事件名改成doubleClick,Capitalize会自动把它处理成onDoubleClick,不用再人工改。

再比如 Redux 的 action type,团队规范要求全部大写下划线风格:

type ActionType<K extends string> = `FETCH_${Uppercase<K>}`; type ProductTypes = ActionType<'sku' | 'price'>; // "FETCH_SKU" | "FETCH_PRICE"

用这种方式,业务侧只要定义模块名或资源名,action type 的全集就自动推导出来。很多人不知道Capitalize只首字母大写,Uppercase是全大写,两者的差别在拼接文件名、组件名时很容易踩到。

2.2 大小写转换的底层限制

我最初以为Uppercase<T>是用条件类型一个个字符转换实现的,后来发现不是。看 TS 源码,Uppercase<T>是编译器内置的 intrinsic 类型,并没有开放同等的自定义能力。这就带来两个使用上的注意点。

第一,如果你传入string这种宽泛类型,结果不会变成具体字面量,而是还是string:

type S = Uppercase<string>; // 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 on<K extends KnownEvent>( name: K, handler: (payload: BusPayloadMap[K]) => void ): void; declare function emit<K extends KnownEvent>( name: K, payload: BusPayloadMap[K] ): void;

这样on和emit的事件名、载荷类型就绑定在一起了。模板字面量类型在这里的价值是:当事件名需要按Module:Action的规则生成时,你可以用BusEvent来约束BusPayloadMap的 key,而不是手写一串重复字符串:

type ValidateEventMap<T extends Record<BusEvent, unknown>> = T; type ValidatedPayloadMap = ValidateEventMap<BusPayloadMap>; // 如果 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 SplitPath<P extends string> = P extends `${infer Head}/${infer Tail}` ? Head extends '' ? SplitPath<Tail> : [Head, ...SplitPath<Tail>] : [P]; type Result = SplitPath<'/user/profile/settings'>; // ["user", "profile", "settings"]

注意开头斜杠会被空字符串占一位,所以要处理Head extends ''的情况。这类解析类型在封装路由库的params推导时会非常有用。

我还试过用infer N extends number从字符串里取出数字字面量。TS 4.8 之后支持这种写法:

type ToNumber<S extends string> = S extends `${infer N extends number}` ? N : never; type Id = ToNumber<'1024'>; // type Id = 1024

当然,真正的运行时代码只要Number('1024')一行就够了。类型层的转换更多是为了在泛型环境里,把外部传入的'1024'自动规约为数字类型,从而省去一次手写断言。

3.3 接口继承与 Action 命名约束

有人问我,模板字面量类型和interface extends有什么关系。其实两者可以配合得很好。先定义一个泛型基接口,然后用模板字面量类型生成最终的type字段:

interface BaseAction<T extends string> { type: T; payload: Record<string, unknown>; } type Resource = 'user' | 'product'; type ListActions = | BaseAction<`FETCH_${Uppercase<Resource>}_LIST`> | BaseAction<`CREATE_${Uppercase<Resource>}`>; 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 BaseAction<'CREATE_USER'> { payload: { name: string }; }

这里'CREATE_USER'仍然满足CREATE_${Uppercase<Resource>}的约束,类型检查能通过,同时又补充了自己特有的 payload 结构。

4. 字符串长度、递归解析和声明文件里的高级用法

4.1 类型层面的字符串长度到底能做什么

有段时间我特别沉迷用模板字面量类型实现"字符串长度"。受限于类型系统的递归深度,它并不能完全替代运行时String.prototype.length,但在某些需要"固定长度字符串"的场景,它能作为编译期约束。

基础实现是每次拆一个字符,剩下的继续递归:

type LengthOfString< S extends string, Acc extends unknown[] = [] > = S extends `${infer _Char}${infer Rest}` ? LengthOfString<Rest, [...Acc, unknown]> : Acc['length']; type NameLen = LengthOfString<'typescript'>; // type NameLen = 10

这个写法在 TS 4.1 之后可行,但要注意:类型递归有深度限制,短字符串没问题,特别长的字符串会让实例化栈溢出。我实测下来,默认深度大约 50 层,通过尾递归优化可以做到几百层,但依然不适合作为通用工具。字符串长度这个需求,运行时一行代码就能拿到准确值,类型层做它有点炫技成分。

真正有价值的不是"测量长度",而是利用递归去解析字符串结构。比如剥离前缀、判断是否包含某个占位符:

type HasPrefix<S extends string, P extends string> = S extends `${P}${string}` ? true : false; type A = HasPrefix<'data-user', 'data-'>; // true type B = HasPrefix<'wrong', 'data-'>; // false

这种模式在做协议解析、配置项校验时很好用。

4.2 字符串逆序、排序和数字转换:类型层能做的边界

模板字面量类型配合递归,还能实现很多看起来像运行时字符串 API 的操作。比如字符串逆序:

type Reverse<S extends string> = S extends `${infer First}${infer Rest}` ? `${Reverse<Rest>}${First}` : ''; type Reversed = Reverse<'hello'>; // 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 on<E 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 Prefix<T> = `prefix-${T}`; // 如果 T 是对象,会报错

正确写法是给泛型加约束:

type Prefix<T extends string | number | boolean | null | undefined> = `prefix-${T}`;

这个extends约束不只是在入口处拦一下,它也在告诉阅读你类型代码的人:这个工具能接受什么输入。我建议在公共类型封装时把约束写全,否则别人传一个Record<string, unknown>进来,报错信息会指向模板字面量类型那一行,而不是调用处,排查起来很费劲。

5.3 如何快速定位类型报错

模板字面量类型导致的报错信息往往又长又绕,特别是联合类型成千上万时。我的调试经验是:

  • 先用一个具体的错误字符串测试工具类型,比如type T = SomeMagicType<'bad-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和靠人肉维护联合类型的日子了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询