模板字面量类型:把字符串写进类型
从「URL 拼错一级路径」切入,讲模板字面量类型:联合笛卡尔积、内置四个字符串工具类型、模式匹配提取路由参数,以及组合爆炸与模糊匹配两个坑。
0. 真实场景:URL 里多了一个字母
FANDEX 的文章页路由长这样:/docs/<分类>/<slug>,站内跳转靠手拼字符串:
const url = `/docs/${category}/${slug}`;
window.location.assign(url);有一次传进去的 category 是 'typescript '(多了个空格),上线后 404。字符串拼接的问题在于:类型系统眼里的 url 是一个 string,任何拼写错误、空格、漏斜杠它都无感。模板字面量类型(TS 4.1 引入)把「字符串的形状」也纳入类型检查——目标是让 /docs/ts/xxx 合法、/doc/ts/xxx 编译报错。
1. 一句话理解
模板字面量类型 = 类型层的模板字符串。值层
`${a}/${b}`拼出运行时的字符串,类型层`${A}/${B}`拼出「字符串形状的类型」:占位处填字面量类型,结果的类型就是拼好的那串字面量;占位处填联合类型,则做笛卡尔积展开。
2. 动手:从约束一个 URL 开始
2.1 约束函数参数的字符串形状
type DocPath = `/docs/${string}/${string}`;
function go(path: DocPath) {
window.location.assign(path);
}
go('/docs/typescript/narrowing'); // 正常
// go('/doc/typescript/narrowing'); // 报错:不符合 `/docs/${string}/${string}`
// go('/docs/typescript'); // 报错:少一级${string} 是「任意字符串」占位。模板类型里的占位符只能是 string、number、bigint、boolean、null、undefined 或它们的联合——放个 object 会直接报错(运行时拼出来也说不清)。
2.2 联合占位:自动笛卡尔积
占位处放联合类型,结果按组合展开:
type Lang = 'zh' | 'en';
type Section = 'docs' | 'blog';
type Route = `/${Section}/${Lang}`;
// '/'+'docs'/'zh' 等四种组合:
// '/docs/zh' | '/docs/en' | '/blog/zh' | '/blog/en'
const r: Route = '/docs/zh'; // 合法
// const r2: Route = '/docs/fr'; // 报错这就是「给每个 section × 每种语言生成合法路由」的零成本写法:路由表加一项,类型自动跟上。
2.3 四个内置字符串工具类型
| 工具类型 | 作用 | 示例 |
|---|---|---|
Uppercase<S> | 全大写 | 'abc' 变 'ABC' |
Lowercase<S> | 全小写 | 'ABC' 变 'abc' |
Capitalize<S> | 首字母大写 | 'width' 变 'Width' |
Uncapitalize<S> | 首字母小写 | 'Width' 变 'width' |
配合映射类型的 as 子句批量生成新键名(键重映射里的 on${Capitalize<K>} 就是它):
type PropToEvent<K extends string> = `on${Capitalize<K>}`;
type E1 = PropToEvent<'click'>; // 'onClick'2.4 模式匹配:从字符串里「拆」出参数
模板类型放在 extends 左侧就成了模式,配 infer 提取任意片段(infer 的机制见infer 专题):
// 从 '/docs/typescript/narrowing' 提取 category 与 slug
type ExtractDocPath<S> =
S extends `/docs/${infer Category}/${infer Slug}` ? { category: Category; slug: Slug } : never;
type P1 = ExtractDocPath<'/docs/typescript/narrowing'>;
// { category: 'typescript'; slug: 'narrowing' }
type P2 = ExtractDocPath<'/blog/2026'>;
// never:模式不匹配多段路径的完整推导(递归拆段)在类型安全的路由里展开,本篇掌握「一段模式 + 两个 infer」即可覆盖大多数场景。
2.5 实用小工具:kebab 转 camel
把 CSS 变量名转成组件属性名,是模板类型的招牌应用:
type KebabToCamel<S extends string> =
S extends `${infer Head}-${infer Rest}`
? `${Head}${Capitalize<KebabToCamel<Rest>>}`
: S;
type T1 = KebabToCamel<'color-surface'>; // 'colorSurface'
type T2 = KebabToCamel<'font-size-base'>; // 'fontSizeBase'读法:按第一个 - 切成两半,后半段递归转换并大写首字母;切不动了(没有 -)原样返回。递归出口就是最后那个 : S。
3. 为什么:类型层也需要「字符串算法」
此前 TS 对字符串的唯一表达是 string,粒度太粗:路由、事件名、CSS 属性、i18n 键、环境变量名……这些「有格式的字符串」全都被吞进一个大类型。模板字面量类型补上这一层后:
- 形状即契约:函数要什么格式的字符串,签名里写出来,调用方拼错当场报错;
- 派生即同步:新加一个
'gallery'section,Route、事件名、CSS 键全部自动长出来,不需要人肉同步三处; - 解析即类型:
ExtractDocPath说明字符串不仅能拼,还能反向拆——这是运行时正则的类型层对应物(没有捕获组那么强,但常见场景够用)。
与运行时模板字符串的关系一句话说清:长得一样、层次不同。运行时那对反引号产出值,类型层这对反引号产出「值的形状」。两者可以在同一处代码各司其职:
function makePath(category: string, slug: string): DocPath {
return `/docs/${category}/${slug}`; // 返回值被约束成合法形状
}4. 坑点与自检
坑 1:组合爆炸拖垮编译器
每个联合占位都是乘法:'a'|'b'|...(20 个)× 4 段就是百万级展开。UI 会卡死、TS2589 会出现。联合成员多时用 ${string} 糊住不关心的段,别追求「每个可能值都精确枚举」。
坑 2:${string} 太贪,吃掉后面的模式
type Bad<S> = S extends `/docs/${string}${infer Rest}` ? Rest : never;
// Rest 几乎总能匹配到,因为 ${string} 可以吞掉任意内容模式匹配从左到右进行,${string} 是「能吃多少吃多少」的模糊段。需要在同一段里既模糊又提取时,把模式收紧(/docs/${string}/${infer Slug}),或用两个工具类型接力。
坑 3:占位处放了不合法的类型
// type Bad = `${Date}`; // 报错:占位只能是 string/number/bigint/boolean/null/undefined
type Ok = `${number}px`; // 合法:'1px' | '12px' | ...坑 4:类型对但运行时错
模板类型只管形状,不管语义:/docs/${string}/${string} 照样接受 /docs/ /。真正的合法性(slug 存在、无空格)仍要运行时校验,见运行时 Schema 校验。
自检清单
- 能写一个带
${string}占位的参数类型约束 URL / 事件名 - 能预测联合占位展开成几个成员
- 背出四个内置字符串工具类型及其用途
- 能读懂并手写一个单层
infer模式匹配 - 知道组合爆炸与
${string}贪婪这两个坑的表现
5. 练习
- 写
Px<S extends number>类型工具:把数字转成像素字面量类型(Px<12>得到'12px'),并用它约束margin参数。 - 用模板类型 + 映射 as,把
{ home: string; about: string }映射成{'/#home': string; '/#about': string}(锚点链接表)。 - 写
StripPrefix<S, P>:去掉字符串开头的指定前缀,不匹配则原样返回(提示:S extends `${P}${infer Rest}`)。