跳到主要内容

第 8 章 · 模板字面量类型

读完本章你会
  • 用模板字面量类型把字符串「拼」出精确的字面量类型
  • 掌握四个字符串工具类型:Uppercase / Lowercase / Capitalize / Uncapitalize
  • 理解 as const 如何让字面量推断保持精确
  • 用模板 + infer 做字符串「模式匹配」:拆前缀、拆后缀、逐字解析
  • 手写 CapitalizeReplaceTrim 等工具

8.1 模板字面量类型

8.1.1 把字面量拼成新字面量

模板字符串语法,在类型层面一样能用——结果是新的字符串字面量类型

type Greeting = `Hello, ${string}`

const a: Greeting = 'Hello, ada' // ✅
const b: Greeting = 'Hi, ada' // ❌ 必须以 "Hello, " 开头

模板里能嵌任意类型,但只有字符串可表示的类型(stringnumberboolean、字面量、它们的联合)能拼出有意义的字面量:

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

本章练习

挑战题

题号题目难度考察点
00110Capitalizemedium首字符 infer + Uppercase
00116Replacemedium首次替换
00119ReplaceAllmedium递归替换
00106Trim Leftmedium左侧空白递归
00108Trimmedium两侧空白
04803Trim Rightmedium右侧空白

这 6 题全是 8.4 节手写版本的「原文重放」。特别留意 ReplaceFrom extends '' 的守卫——那是防止空串匹配导致无限递归的关键,也是 00116/00119 的测试会专门考察的点。

代码练习

  1. 模板拼接:用模板字面量定义一个 CssClass<Prefix extends string, Names extends string>,验证 CssClass<'btn', 'primary' | 'danger'>'btn-primary' | 'btn-danger'

  2. 字符串工具:写 ToConstant<S>kebab-case / camelCase 转成全大写常量名('user-profile''USER_PROFILE')。提示:逐字符处理大写字母边界 + 替换 -

  3. as const 联合:定义一个 STATUS 常量对象(as const),用 keyof typeof + (typeof X)[keyof typeof X] 取出值联合,验证拼模板能得到精确字面量。

  4. 模式匹配:写 type ExtractId<S extends string>,从 '/users/:id' 这类字符串里抽出 :id(提示:匹配 ':${infer Id}'${infer A}/:${infer Id}),验证对 /posts/:postId/comments 得到 'postId'

  5. 递归解析:用 TrimLeft + 递归写 type StripWhitespace<S> 去掉所有空白(不只是首尾),验证 StripWhitespace<' a b c '>'abc'