第 8 章 · 模板字面量类型
- 用模板字面量类型把字符串「拼」出精确的字面量类型
- 掌握四个字符串工具类型:
Uppercase/Lowercase/Capitalize/Uncapitalize - 理解
as const如何让字面量推断保持精确 - 用模板 +
infer做字符串「模式匹配」:拆前缀、拆后缀、逐字解析 - 手写
Capitalize、Replace、Trim等工具
8.1 模板字面量类型
8.1.1 把字面量拼成新字面量
模板字符串语法,在类型层面一样能用——结果是新的字符串字面量类型:
type Greeting = `Hello, ${string}`
const a: Greeting = 'Hello, ada' // ✅
const b: Greeting = 'Hi, ada' // ❌ 必须以 "Hello, " 开头
模板里能嵌任意类型,但只有字符串可表示的类型(string、number、boolean、字面量、它们的联合)能拼出有意义的字面量:
type Size = 'sm' | 'md' | 'lg'
type ButtonClass = `btn-${Size}`
// 'btn-sm' | 'btn-md' | 'btn-lg' —— 联合被自动展开!
type Version = `v${1 | 2}.${0 | 1}`
// 'v1.0' | 'v1.1' | 'v2.0' | 'v2.1' —— 笛卡尔积!
btn-${Size} 对联合的每个成员各生成一个结果,再并成联合。这其实是「分布式」思想在字符串拼接上的体现——两个联合拼一个模板,结果是它们的笛卡尔积。
8.1.2 用途:把「取值范围」编码进字符串
模板字面量最适合描述「有固定结构的字符串」:
// 事件名:`${prefix}:${name}`
type EventName<P extends string, N extends string> = `${P}:${N}`
const e: EventName<'ui' | 'data', 'click' | 'load'> = 'ui:click' // ✅
// 日期字符串
type DateStr = `${number}-${number}-${number}`
const d: DateStr = '2026-08-10' // ✅
// 国际化 key:`${namespace}.${key}`
type I18nKey = `${'common' | 'order'}.${string}`
这类约束在普通 string 上无法表达——模板字面量把「格式」也变成了类型的一部分。
8.2 四个字符串工具类型
| 工具 | 作用 | 例子 |
|---|---|---|
Uppercase<S> | 全大写 | Uppercase<'abc'> → 'ABC' |
Lowercase<S> | 全小写 | Lowercase<'ABC'> → 'abc' |
Capitalize<S> | 首字母大写 | Capitalize<'abc'> → 'Abc' |
Uncapitalize<S> | 首字母小写 | Uncapitalize<'Abc'> → 'abc' |
它们配合模板字面量,是「改字符串格式」的基石:
type KebabToPascal<S extends string> = Capitalize<S>
type A = KebabToPascal<'foo-bar'> // 'Foo-bar'(只动首字母)
// 配合模板拼接做「事件名转常量名」
type EventToConst<E extends string> = `${Uppercase<E>}`
type B = EventToConst<'userCreated'> // 'USERCREATED'(全大写)
Capitalize 有个隐藏用处:判断一个字符是否字母。因为非字母字符(数字、标点、emoji)经 Capitalize 后不变:
type IsLetter<C extends string> =
C extends Uppercase<C>
? C extends Lowercase<C> ? false : true // 大小写转换后仍相同 → 不是字母
: true
这是类型体操里「判断字符类型」的常用技巧。
8.3 as const 与字面量推断
8.3.1 问题:字面量被放宽
对象属性默认推断成宽类型,模板拼出来就「白拼了」:
const theme = { mode: 'dark' }
type M = `mode-${typeof theme.mode}` // 'mode-string' ❌ 不是 'mode-dark'
8.3.2 解决:as const
const theme = { mode: 'dark' } as const
type M = `mode-${typeof theme.mode}` // 'mode-dark' ✅
as const 让字面量保持精确,模板才能拼出精确结果。做类型体操前,先 as const——这是最常见的「为什么拼出来是 string」的解法。
8.3.3 从常量对象生成联合
as const + 模板 + keyof typeof 三件套,能从一个配置对象生成一堆类型:
const ROUTES = {
home: '/',
users: '/users',
userDetail: '/users/:id',
} as const
type RouteKey = keyof typeof ROUTES // 'home' | 'users' | 'userDetail'
type RoutePath = (typeof ROUTES)[RouteKey] // '/' | '/users' | '/users/:id'
type DynamicRoute = `${RoutePath}` // 同 RoutePath
8.4 模板 + infer:字符串模式匹配
这是模板字面量最强大的能力——在模板里放 infer,把字符串拆开。
8.4.1 拆前缀 / 拆后缀
// 匹配「以某前缀开头」
type StartsWith<T extends string, U extends string> =
T extends `${U}${infer Rest}` ? true : false
type A = StartsWith<'foobar', 'foo'> // true
type B = StartsWith<'barfoo', 'foo'> // false
// 匹配「以某后缀结尾」
type EndsWith<T extends string, U extends string> =
T extends `${infer Rest}${U}` ? true : false
type C = EndsWith<'foobar', 'bar'> // true
${infer Rest} 会吞多少?模板里的 infer 匹配最短的可行前缀/后缀。例如把 'foobar' 匹配成「'foo' 开头 + 任意剩余」时,Rest 会得到 'bar'。TS 会正确地在「第一个能匹配的 foo」处拆分。
8.4.2 手写 Capitalize
Capitalize 本质就是「拆首字符 + 转大写 + 拼回」:
type MyCapitalize<S extends string> =
S extends `${infer F}${infer R}`
? `${Uppercase<F>}${R}` // 首字符大写,其余原样
: S // 空字符串,原样
type A = MyCapitalize<'hello'> // 'Hello'
type B = MyCapitalize<''> // ''
8.4.3 手写 Replace:替换第一个匹配
type Replace<S extends string, From extends string, To extends string> =
From extends '' // 空 From 直接返回原串(避免死循环)
? S
: S extends `${infer L}${From}${infer R}`
? `${L}${To}${R}` // 匹配到,替换第一处
: S
type A = Replace<'foobarbar', 'bar', 'foo'> // 'foofoobar'
type B = Replace<'foobarbar', 'zz', 'x'> // 'foobarbar'(没匹配到)
8.4.4 手写 ReplaceAll:替换所有
ReplaceAll = 替换一处后,对剩余部分继续递归:
type ReplaceAll<S extends string, From extends string, To extends string> =
From extends ''
? S
: S extends `${infer L}${From}${infer R}`
? `${L}${To}${ReplaceAll<R, From, To>}` // 递归处理 R
: S
type A = ReplaceAll<'foobarbar', 'bar', 'foo'> // 'foofoofoo'
8.4.5 手写 Trim:去掉首尾空白
Trim = 先去掉左侧空白,再去掉右侧空白;每一步都是一个「模式匹配 + 递归」:
type Whitespace = ' ' | '\n' | '\t'
type TrimLeft<S extends string> =
S extends `${Whitespace}${infer R}` ? TrimLeft<R> : S
type TrimRight<S extends string> =
S extends `${infer L}${Whitespace}` ? TrimRight<L> : S
type Trim<S extends string> = TrimRight<TrimLeft<S>>
type A = Trim<' hello '> // 'hello'
TrimLeft:只要开头是空白就拆掉、继续;拆不动了返回剩余。TrimRight 同理从尾部拆。递归条件类型 + 模板模式匹配 = 字符串解析的基础能力,第 15 章会把它用到极致。
本章小结
- 模板字面量类型把「格式」编进类型;嵌联合自动展开成笛卡尔积
Uppercase/Lowercase/Capitalize/Uncapitalize处理大小写as const保住字面量精确性,模板拼接才有效- 模板 +
infer是字符串「模式匹配」的基础:拆前缀、拆后缀、逐字符 - 递归 + 模式匹配可手写
Capitalize/Replace/ReplaceAll/Trim
本章练习
挑战题
| 题号 | 题目 | 难度 | 考察点 |
|---|---|---|---|
| 00110 | Capitalize | medium | 首字符 infer + Uppercase |
| 00116 | Replace | medium | 首次替换 |
| 00119 | ReplaceAll | medium | 递归替换 |
| 00106 | Trim Left | medium | 左侧空白递归 |
| 00108 | Trim | medium | 两侧空白 |
| 04803 | Trim Right | medium | 右侧空白 |
这 6 题全是 8.4 节手写版本的「原文重放」。特别留意
Replace里From extends ''的守卫——那是防止空串匹配导致无限递归的关键,也是 00116/00119 的测试会专门考察的点。
代码练习
-
模板拼接:用模板字面量定义一个
CssClass<Prefix extends string, Names extends string>,验证CssClass<'btn', 'primary' | 'danger'>是'btn-primary' | 'btn-danger'。 -
字符串工具:写
ToConstant<S>把kebab-case/camelCase转成全大写常量名('user-profile'→'USER_PROFILE')。提示:逐字符处理大写字母边界 + 替换-。 -
as const 联合:定义一个
STATUS常量对象(as const),用keyof typeof+(typeof X)[keyof typeof X]取出值联合,验证拼模板能得到精确字面量。 -
模式匹配:写
type ExtractId<S extends string>,从'/users/:id'这类字符串里抽出:id(提示:匹配':${infer Id}'或${infer A}/:${infer Id}),验证对/posts/:postId/comments得到'postId'。 -
递归解析:用
TrimLeft+ 递归写type StripWhitespace<S>去掉所有空白(不只是首尾),验证StripWhitespace<' a b c '>为'abc'。