模板字面量类型(Template Literal Types)是 TypeScript 4.1 引入的一套“字符串加工”能力,简单说就是让你在类型世界里像写模板字符串一样做字符串拼接、拆分和模式匹配。当年我第一次看到type Greeting = \Hello, ${string}`` 这种写法时,心里想的是:这不就是把 JS 模板字符串搬到类型里了吗?后来用多了才发现,这东西远比“拼字符串”要深,它真正解决的是类型系统里“字符串形态无法表达、无法推导”的老大难问题。今天这篇文章就把我实际项目里用模板字面量类型踩过的坑、总结出来的套路,一次说清楚。
这篇文章适合两类读者:一类是已经在用 TypeScript,但主要停留在 interface / 泛型 / 联合类型,想进阶类型编程的;另一类是听说过模板字面量类型,但不知道它到底能在真实业务里干什么,看文档也看不明白的。我会从最基础的“长什么样”讲起,一路折腾到递归模板、路由参数推导、事件名自动生成,最后把我实际遇到的坑和排查思路整理成一份速查表。
1. 模板字面量类型:先搞清楚它在“类型层面”干的事
1.1 模板字符串的“类型版”:从语法到语义
在 JavaScript 里,模板字符串就是反引号包起来,用${}嵌入变量:
const name = '张三' const str = `你好,${name}`模板字面量类型的语法几乎一模一样,区别只是把${}里的内容从“值”换成“类型”:
type Greeting = `你好,${string}` type UserId = `user_${number}`这里有个很关键的点:\你好,${string}`` 的单引号里不是“邀请你参加”的某一条字符串命令,而是用户字符串类型的“模式”,只要符合“以‘你好,’开头、后面跟任意字符串”这种结构的字符串字面量类型,都能被这个类型囊括。
于是你会立刻看到它和普通string的区别。string是“任意字符串都行”,而模板字面量类型是“符合特定形态的字符串才行,并且形态里的细节可以被类型系统保留下来”。说白了,string是一个宽泛的集合,模板字面量类型是一套带结构的模式。这个“结构”正是泛型推导、类型守卫、代码补全能够往下走的根基。
再看一个细节。如果你直接把普通变量拼进模板字符串类型,编译器会要求这个变量必须是一个字面量类型,而不是宽泛的string:
const prefix: string = 'api' type BadEndpoint = `${prefix}/user` // 报错:prefix 是 string,无法用在模板字面量类型里为什么?因为模板字面量类型要求每一个插槽位置都是静态可枚举的。如果prefix是string,那就意味着这个类型是“任意字符串 + '/user'”,结果等于string,什么结构信息都丢光了,类型系统自然不接受。这是很多新手第一次碰壁的地方。解决方案是先写const prefix = 'api' as const,或者直接定义一个字面量类型别名。
1.2 为什么原生类型系统搞不定这种“带结构字符串”
在模板字面量类型出现之前,如果你要做“接口请求成功的回调事件名”,普遍做法是手写一个庞大的联合类型:
type ApiEvent = | 'login_success' | 'login_fail' | 'logout_success' | 'logout_fail' | 'profile_success' | 'profile_fail'这写法有几个致命问题。第一,每加一个 API 就要手动补两到三行字符串,漏一个就只能在运行时炸出来。第二,字符串之间没有任何逻辑关系,你想从一个login_success反推出它属于login这个 API,只能靠正则或者写个 工具函数,类型系统完全不参与。第三,一旦要在代码里用map遍历所有可能的事件名,联合类型里的重复内容还得保证手写不抄错。
模板字面量类型就是把“结构的生成”交给类型系统。你给我两个基础集合:API 名字的联合类型 + 状态的联合类型,我自动生成所有组合:
type ApiName = 'login' | 'logout' | 'profile' type ApiState = 'success' | 'fail' type ApiEvent = `${ApiName}_${ApiState}`这样ApiEvent自动展开成六个字符串字面量类型的联合,新增一个 API 只需要往ApiName里加一项。更重要的是,这个ApiEvent是可反向推导的——后面我们会看到,用infer可以从一个具体的login_success里把login和success分别抓出来。这一步“从联合类型到组合类型,再到模式提取”的闭环,才是模板字面量类型真正的价值。
2. 核心玩法拆解:从“拼”到“拆”再到“验”
2.1 联合类型做“笛卡尔积”:自动生成组合字符串
我们先从最简单的“拼”开始。模板字面量类型里,只要${}内部放的是一个联合类型,结果自动是两个联合类型的笛卡尔积:
type Method = 'GET' | 'POST' | 'PUT' type Result = 'OK' | 'ERROR' type HttpResult = `${Method}_${Result}` // 'GET_OK' | 'POST_OK' | 'PUT_OK' | 'GET_ERROR' | 'POST_ERROR' | 'PUT_ERROR'这个特性用来生成 action type、事件名、样式类名前缀,可以说再合适不过。我在真实项目里做过一个配置驱动的表单校验器,每个字段都有校验类型(required、minLength、maxLength、pattern),每个类型对应一条错误消息 key。早期我是手写一个联合类型,后来改成模板自动拼:
type FieldName = 'username' | 'password' | 'email' type Validator = 'required' | 'minLength' | 'maxLength' | 'pattern' type ErrorKey = `${FieldName}_${Validator}` const errorMessages: Record<ErrorKey, string> = { username_required: '请输入用户名', username_minLength: '用户名至少 3 个字符', // 漏一个字段会直接编译报错! }这里的关键收益就是穷尽性检查。只要Record<ErrorKey, string>里的对象少写一个 key,TS 编译器就会直接报错。这种“类型系统倒逼配置完整”的体验,手写联合类型永远给不了。当字段从三个扩展到十几个时,节省的时间是肉眼可见的。
还要提醒一下:拼接的顺序、分隔符用什么,非常影响后续的可读性和推导难度。一般建议用_、/、:这种语义清晰的分隔符。想用驼峰拼接也不是不行,但后面用Capitalize之类的内置类型时要注意大小写处理。
2.2 用 infer 从字符串里“抠”出关键片段
拼是为了生成,拆是为了解析。模板字面量类型可以和条件类型 +infer组合出非常强悍的“字符串解析器”。
比如我们做一个/api/user/123这种路径的解析,想把路径参数类型提取出来:
type ExtractParam<Path extends string> = Path extends `/${infer Segment}` ? Segment : never type A = ExtractParam<'/api/user/123'>这里能匹配上/${infer Segment},因为字符串确实以/开头,所以Segment就变成api/user/123。但这只是一个粗粒度拆分,如果我要逐段拆解路径,还需要配合递归。
先讲一个更常用的场景:从一个事件字符串里提取 API 名称和状态。
type SplitEvent<E extends string> = E extends `${infer Api}_${infer State}` ? { api: Api; state: State } : never type EventInfo = SplitEvent<'login_success'> // { api: 'login'; state: 'success' }我自己实际用最多的是路由参数提取。后端给的 REST 路径是/users/:id/profile,我需要让 TS 自动推断出参数键id,然后约束传给请求函数的 params 对象。配合递归模板我们可以做到,但这一步的核心机制仍然是infer——它让类型系统有能力“匹配一段再抓取一段”,这就相当于在类型世界里开了正则表达式模式捕获。
注意一个坑:infer默认是非贪婪的。什么意思?比如E extends \${infer Api}_${string}`,如果E是login_success_fail,Api推断为login还是login_success?它会尝试最短匹配,所以Api是login,剩下的success_fail被第二个${string}整体吞掉。如果你希望从第一个下划线开始切,这种写法是准确的;如果你要按最后一个下划线切,就需要换一种匹配策略(比如配合递归)。很多人在模板类型上翻车,就是因为没读懂infer` 的非贪婪规则。
2.3 内置转换工具:Capitalize 与“动词前缀”自动生成
模板字面量类型除了拼接和提取,还可以配合 TypeScript 内置的Capitalize、Uncapitalize、Uppercase、Lowercase对字符串首字母等做大小写转换。这个组合最常见的应用是生成onClick、onSubmit这类事件处理器名称。
type EventName<N extends string> = `on${Capitalize<N>}` type ClickHandlerName = EventName<'click'> // 'onClick' interface ButtonProps { onTextChange: () => void }关键在于Capitalize只把首字母转大写,不会动后面的内容。所以你可以拿它去给组件的事件 props 做自动映射,省去手写一串重复的事件名。加上satisfies或Record组合,还能实现“事件名必须匹配组件实际事件”。
一个真实的例子,我在做表格组件的时候,把列配置项里的小写 key 自动映射成渲染函数的名字:
type ColumnKey = 'name' | 'age' | 'action' type RendererName = `render${Capitalize<ColumnKey>}` // 'renderName' | 'renderAge' | 'renderAction' const renderers: Record<RendererName, (row: any) => React.ReactNode> = { renderName: (row) => row.name, renderAge: (row) => row.age, renderAction: (row) => <button>操作</button>, }这里又牵扯到擅长扩展的一个问题:Capitalize<ColumnKey>是一个 distributive 操作吗?实际上它作用于联合类型时,TypeScript 会自动展开到联合类型的每个成员上,这个是内置工具类型的行为,不需要额外处理。如果你要自定义一个仿Capitalize的转换,就得注意联合类型的展开顺序,防止结果嵌套成联合里的联合。
3. 进阶实战:用递归模板做“类型级字符串运算”
3.1 类型层面的字符串长度计算:从拆分到计数
模板字面量类型真正“魔法化”的时刻,是它和递归组合出类型级算法。我们先从最经典的“计算字符串长度”开始,你会发现它把一个看起来完全属于运行时的问题搬到了类型层。
type LengthOfString<S extends string, A extends string[] = []> = S extends `${infer Char}${infer Rest}` ? LengthOfString<Rest, [Char, ...A]> : A['length'] type Length = LengthOfString<'hello'> // 5这段代码的思路:每次递归从S里取出首字符Char,把剩下的字符串Rest继续递归,同时把这个Char塞进一个元组A。当S为空字符串时,递归结束,A['length']就是字符数。
这里有个必须强调的点:为什么用元组的length而不是直接定义一个数字变量累加?因为类型系统里没有“变量自增”这种操作,数字是没法直接“加一”的。元组就相当于一个可增长的计数器,[...A, Char]每递归一次就把元组撑大一个元素,最后取length。这是类型编程里一个基础且高频的手法,叫“元组计数”。
实际项目里这个“字符串长度”有什么价值?比较常见是用于验证固定长度的业务编码,比如订单号、优惠券码必须 8 位:
type OrderCode = string & { __brand: 'OrderCode' } type CheckOrderCode<S extends string, L extends number> = LengthOfString<S> extends L ? S : never const validCode: CheckOrderCode<'A1B2C3D4', 8> = 'A1B2C3D4' // 正确,类型就是 'A1B2C3D4'虽然这种“品牌类型”做法在实际业务中不算必需品,但当你需要给后端返回的字符串类型加一层“在线校验”时,这种类型级约束可以提前挡住很多手误。
3.2 类型级 split:把字符串拆成字符元组
字符串长度计算本质上依赖“拆分字符串”。掌握了拆分,你就能实现很多更高层的能力,比如把一个字符串转成字符联合、按分隔符拆成元组、提取所有大写字母等。
反过来先做字符拆分函数,这是很多字符串工具类型的基座。
type StringToTuple<S extends string> = S extends `${infer Char}${infer Rest}` ? [Char, ...StringToTuple<Rest>] : [] type Tuple = StringToTuple<'abc'> // ['a', 'b', 'c']这个StringToTuple一出来,配合元组的[number]索引,就能得到字符联合:
type CharUnion = StringToTuple<'abc'>[number] // 'a' | 'b' | 'c'再近一步,按分隔符拆分的Split也类似,只是判断条件不同:
type Split<S extends string, Sep extends string> = S extends `${infer Left}${Sep}${infer Right}` ? [Left, ...Split<Right, Sep>] : [S] type PathParts = Split<'/users/:id/profile', '/'> // ['', 'users', ':id', 'profile']注意这里我用了分隔符/,结果第一个元素是空字符串'',因为字符串以/开头,空串也被保留了下来。后续处理路径参数时,需要过滤掉空串以及不以:开头的片段。
这里再分享一个经验:逗号分隔的数据、换行符解析,都可以用同一个套路做。我早前接手过一个远端配置系统,配置项是key=value每行一组的字符串,我用这个套路直接在类型层把配置拆出来了,编译器就能校验配置项是否完整,省掉了运行时解析步骤。
3.3 路由参数推导:把 URL 模板变成类型安全配置
把前面几招组合起来,我们就能做一个最实用的案例:从路径模板推导参数名,并严格约束传入的 params。
假设路径模板是/users/:id/profile/:page,期望得到类型{ id: string; page: string }。
实现思路:先把路径按/拆分,得到['', 'users', ':id', 'profile', ':page'],再过滤掉不以:开头的元素,剩下[':id', ':page'],再把:前缀去掉变成参数名。
完整类型如下:
type Split<S extends string, Sep extends string> = S extends `${infer Left}${Sep}${infer Right}` ? [Left, ...Split<Right, Sep>] : [S] type RemoveEmpty<T extends string[]> = T extends [infer First, ...infer Rest] ? First extends string ? First extends '' ? RemoveEmpty<Rest> : [First, ...RemoveEmpty<Rest>] : never : [] type ExtractParams<T extends string[]> = T extends [infer First, ...infer Rest] ? First extends `:${infer Name}` ? { [K in Name]: string } & ExtractParams<Rest> : ExtractParams<Rest> : {} type RouteParams<Path extends string> = ExtractParams<RemoveEmpty<Split<Path, '/'>>> type Params = RouteParams<'/users/:id/profile/:page'> // { id: string } & { page: string }这个类型的实际价值是肉眼可见的。配上一个请求函数:
function request<Path extends string>( path: Path, params: RouteParams<Path>, ) {} request('/users/123/profile/2', { id: '123', page: '2' }) // 正确 request('/users/123/profile/2') // 报错,缺少 id 和 page当路由配置加了一个:tab参数,所有调用处立刻亮红灯,不用等后端接口报错。我自己在做中后台系统时,把这种类型安全路由对接到了 React Router 里,路由表的每个 path 都过一遍这个类型,显著减少了联调时“参数写错”这类低级 Bug。
也许还有一个优化方向:参数值类型有时不只是string,比如:id是数字,:page枚举了'1' | '2' | '3'。那可以在类型上再接一个参数 schema,把每个参数名映射到具体类型,这个可以当作练习留给读者。
4. 常见问题与排查实录:我在模板字面量类型上踩过的雷
4.1 一用${string}匹配,infer 就变成宽泛 string
新手最容易遇到的一个诡异情况:明明我匹配了/user/${infer Rest},为什么Rest的类型不是我以为的那个字面量,而是string?
原因其实在输入值上。如果你传入的Path本身就是一个string类型,那么模板匹配时infer Rest自然推断成string,因为类型系统只知道“它可能是任何字符串”。举个例子:
declare const path: string type T = path extends `/user/${infer Rest}` ? Rest : never // T 是 string这在泛型里尤其常见。调用方如果没有把字符串字面量类型传进来(比如从某个 API 返回的string字段),那么类型层面就真的无法进一步拆解了。要解决这个问题,有两个思路。
思路一:尽量在入口使用as const或字面量类型。比如路由配置里写as const,确保字符串不会“变宽”成string。
思路二:用泛型约束而不是直接断言。定义function request<Path extends string>(path: Path, ...),让 TS 保留传入值的最窄类型。如果你写成function request(path: string, ...),再好的模板类型也救不回来。
因此排查这类问题时要先看传参的类型是否已经被“拓宽”了,不要一上来就怀疑模板写法。
4.2 “Type instantiation is excessively deep” 无限递归
递归模板用多了就会遇到这条报错。原因通常是字符串太长,导致递归次数超过编译器设的深度限制(默认一般是 50 层左右)。比如你拿上面那个LengthOfString去计算 100 个字符的字符串,它会直接罢工。
应对方法不是把类型改复杂,而是减少每层递归的“消耗”。标准做法是每次递归多吃掉几个字符,比如一次拆两个字符,递归深度立刻减半:
type LengthOfString2< S extends string, A extends string[] = [], > = S extends `${infer A}${infer B}${infer Rest}` ? LengthOfString2<Rest, [A, B, ...A]> : S extends `${infer A}${infer Rest}` ? LengthOfString2<Rest, [A, ...A]> : A['length']这样的“贪心”递归能把搜索深度大幅降低。另一个思路是用尾递归优化,但 TS 编译器对类型递归的尾递归优化支持并不完全可靠,所以最保险的办法仍然是控制字符串长度。实际项目中我会怀疑:一个真需要鉴别 100 位以上字符串长度的需求,是不是应该在运行时做而不是在类型层做。类型层最适合的是处理短小的枚举字符串、路由 path、事件名,而不是长文本。
还有一个小技巧:遇到这种报错时,把大类型拆成多个小类型,分步中间映射,既便于排查也能让编译器少做一次性展开。
4.3 可分配性陷阱:模板字面量类型不一定能赋给 string 的“子类型”
有时候你会纠结:type ApiVersion = \v${number}`到底是不是string的子类型?它当然是,它代表的是“以 v 开头、后跟数字”的字符串集合,所以可以赋给string`。
但反过来,string不能赋给ApiVersion,因为string里有很多不符合v${number}模式的字符串。这个只要理解了集合关系就不会踩坑。
比较隐蔽的一个点发生在对象 key 推断里。比如:
type Prefix = 'pre' type Key = `${Prefix}_${string}` const map: Record<Key, number> = {} map.abc = 1 // 报错,因为 'abc' 不是 'pre_xxx' map.pre_abc = 1 // 正确看起来合理,但如果你在函数参数里让用户传一个“任意字符串”,然后内部假设它一定是pre_开头,类型系统不会自动帮你做这个断言,你得显式写satisfies或者用Extract。还有就是模板字面量类型里的${string},它并不代表“任意字符串”,而是“任意字符串都存在于此位置”的模式,也就是说\pre_${string}`仍然是pre_` 开头的子集,不是全部字符串。
最后给一份速查记忆:
| 场景 | 典型报错/问题 | 排查要点 |
|---|---|---|
| infer 变成宽泛 string | 输入被拓宽成string | 用泛型保留字面量,入口加as const |
| 递归太深 | Type instantiation is excessively deep | 一次多拆字符,或缩小输入长度 |
| 模板类型不能赋给某个字符串 | 可分配性报错 | 检查${string}模式是否覆盖目标集合 |
多个infer匹配不符合预期 | 返回值不是想要的 | 记住 infer 是非贪婪匹配,必要时换递归策略 |
| 嵌套联合类型展开混乱 | 结果出现“联合里的联合” | 先展开到元组或数组,再做条件分发 |
4.4 类型抽取过长、IDE 卡顿怎么办
这个不算报错,但很影响体验。当你写了非常复杂的递归模板类型,编辑器悬停提示会变得很慢,甚至会卡顿几秒。遇到这种情况,第一选择是给中间结果起名字。不要把一大坨模板类型塞在函数签名里,定义一个type RouteInfo<P extends string> = ...,需要看结果时单独悬停RouteInfo<'/users/:id'>。这样既方便调试,也能让 TS 缓存在语义层复用类型别名,降低单次计算的成本。
另外,如果类型计算重的部分只有特定模块需要,可以像运行时代码一样,单独建一个types/route.ts,把复杂的字符串工具类型集中在里面,配合.d.ts文件做隔离。这样写业务代码时,普通模块不会被这些重类型拉低编译速度。
5. 应用场景盘点:什么时候该上模板字面量类型
文章最后,我想把视野拉开一点,看看模板字面量类型在真实项目里到底能用到哪些地方。这能帮你判断,新项目里什么时候值得写一套“字符串类型工具”。
第一类:事件系统。无论是 Redux 的 action type,还是组件的事件名,用${Module}_${Action}_${State}这种模板组合出完整事件枚举,加上后面再配合infer反向提取模块名做自动归组,非常顺手。这个场景对类型穷尽性要求高,收益也最高。
第二类:路由系统。用模板拆分 path,自动生成路径参数类型。前端 SPA、后端框架的 API schema、甚至小程序路由表都能用上。我自己维护过一个路由配置表,所有路由 path 都写死为字面量字符串,调用跳转时的 params 全部类型检查过。
第三类:表单字段与校验规则联动。像前面举的ErrorKey例子,字段名和校验规则组合生成所有错误消息 key,让配置完整性在编译期可见。数据驱动表单越大,这个收益越明显。
第四类:代码生成与 API 客户端。从 OpenAPI 文档字符串里解析路径模板,生成带类型参数的请求函数,本质也依赖模板字面量类型的字符串拆分和提取能力。
不过也要给大家泼一点冷水。模板字面量类型不是“银弹”,它适合处理短小、枚举值有限的字符串结构。一旦字符串长度极长、模式过于复杂,类型层的递归和推断会变成负担,这时候更好的方案是把逻辑下沉到运行时用 zod 之类的 schema 校验库校验,类型层只做静态接口定义。类型编程的价值是“编译期发现问题”,不是替代所有运行时校验。
我自己在项目里一个很实用的搭配是:运行时用 zod 解析字符串结构,类型层用模板字面量类型保持对字面量的约束。两者取长补短,既拿到类型安全,又不在复杂字符串匹配上死磕。如果你第一次尝试模板字面量类型,我建议你从一个很小的场景入手——比如先把你手写的事件名联合类型替换成模板自动生成。等你对infer的匹配规则、递归实现、可分配性边界都有手感之后,再逐步扩展到路由解析、配置驱动这些更复杂的场景。踩过几次坑之后你会发现,它确实像拼字符串一样拼类型,只不过这个“字符串”承载的不只是文本,而是整个业务结构的约束力。