普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月22日首页

你不知道的 TypeScript:模板字符串类型

2026年1月21日 22:49

大部分前端应该都或多或少地使用过 TypeScript 开发,但是此文将带你探索少有人了解的 TS 领域:模板字符串类型。

这样的类型操作你见过吗?

type World = 'World'
type Greeting = `Hello ${World}` // "Hello World"

type UserName = 'cookie'
type UserNameCapitalize = Capitalize<UserName> // "Cookie"

type ButtonVariant = `btn-${'primary' | 'secondary'}-${'sm' | 'lg'}`
// "btn-primary-sm" | "btn-primary-lg" | "btn-secondary-sm" | "btn-secondary-lg"

看起来不可思议,但是这些都是 TypeScript 模板字符串类型的能力。

模板字符串类型(Template Literal Types) 是 TypeScript 4.1 引入的高级特性。它建立在字符串字面量类型的基础上,允许你通过类似 JavaScript 模板字符串的语法,动态地组合、操作和生成新的字符串类型。

接下来,我将从字符串字面量类型开始,逐步讲解到模板字符串类型的初级到高级的用法。

一、基础:什么是字符串字面量类型?

1. 定义

字符串字面量类型是指将一个具体的字符串值作为一种类型

// 普通的 string 类型
let s1: string = 'hello'
s1 = 'world' // 正确

// 字符串字面量类型
let s2: 'hello' = 'hello'
s2 = 'world' // 报错:不能将类型"world"分配给类型"hello"

2. 联合类型 (Union Types)

字符串字面量类型最常见的用法是配合联合类型使用,限制变量只能是某几个特定字符串之一。

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
function request(method: HttpMethod, url: string) {
  // ...
}
request('GET', url) // 正确
request('LET', url) // 报错:类型"LET"的参数不能赋给类型"HttpMethod"的参数。

二、进阶:模板字符串类型

1. 基础拼接

就像 JavaScript 中的模板字符串 ${var} 一样,TypeScript 类型也可以通过反引号 ``${} 配合实现字符串类型的插值。

// 字符串拼接
type World = 'World'
type Greeting = `Hello ${World}` // 类型 "Hello World"

// 多个插值
type Protocol = 'https'
type Domain = 'example.com'
type URL = `${Protocol}://${Domain}` // 类型 "https://example.com"

// 与其他类型结合
type Version = 1 | 2 | 3
type APIVersion = `v${Version}` // "v1" | "v2" | "v3"

在这里提醒一下大家,一定不要把类型和值搞混,这里操作的是类型,不要把它当做值去操作。一些错误示例:

console.log(World) // 报错:"World"仅表示类型,但在此处却作为值使用。
type Greeting = "Hello" + World // 报错:"World"仅表示类型,但在此处却作为值使用。

2. 联合类型的自动分发(笛卡尔积)

在模板字符串中,把多个联合类型组合在一起时,TypeScript 会自动生成所有可能的组合,也就是按笛卡尔积(Cartesian Product)组合。

type Space = 'sm' | 'md' | 'lg'
type Side = 't' | 'b' | 'l' | 'r'
type PaddingClass = `p-${Side}-${Space}`
// "p-t-sm" | "p-t-md" | "p-t-lg" | "p-b-sm" | "p-b-md" | "p-b-lg" | "p-l-sm" | "p-l-md" | "p-l-lg" | "p-r-sm" | "p-r-md" | "p-r-lg"

// 实际使用
function addPadding(el: HTMLElement, className: PaddingClass) {
  el.classList.add(className)
}
const div = document.createElement('div')
addPadding(div, 'p-t-sm') // 正确
addPadding(div, 'p-x-xx') // 类型错误

性能提示:当联合类型的成员数量较多时,组合后的类型数量会呈指数级增长(例如 3 个联合类型各有 10 个成员,组合后会有 1000 种可能),这可能会导致 TypeScript 编译器性能下降或类型检查变慢。

3. 内置工具类型

为了性能和方便,TypeScript 编译器内置了四个特殊的工具类型。它们不是通过 TS 代码实现的,而是直接由编译器处理。

  • Uppercase<S> 将字符串中的每个字符转换为大写
  • Lowercase<S> 将字符串中的每个字符转换为小写
  • Capitalize<S> 将字符串的第一个字符转换为大写
  • Uncapitalize<S> 将字符串的第一个字符转换为小写

下面是使用示例:

// Uppercase:全部转大写
type Color = 'red'
type UpperColor = Uppercase<Color> // "RED"

// Lowercase:全部转小写
type MixedCase = 'TypeScript'
type LowerCase = Lowercase<MixedCase> // "typescript"

// Capitalize:首字母大写
type Name = 'cookie'
type CapName = Capitalize<Name> // "Cookie"

// Uncapitalize:首字母小写
type Components = 'Button' | 'Input' | 'Modal'
type UncapComponents = Uncapitalize<Components> // "button" | "input" | "modal"

结合模板字符串使用:

// 生成事件处理器名称
type Events = 'click' | 'change' | 'input'
type EventHandlers = `on${Capitalize<Events>}`
// "onClick" | "onChange" | "onInput"

// 生成 CSS 类名
type Size = 'sm' | 'MD' | 'Lg'
type SizeClass = `size-${Lowercase<Size>}`
// "size-sm" | "size-md" | "size-lg"

三、 高阶:模式匹配与 infer

掌握了基础的模板字符串拼接后,接下来我们进入更强大的领域——模式匹配。要在类型中解析字符串(例如提取参数、去掉空格),我们需要结合 extendsinfer

1. 什么是 infer

infer 属于 TS 的高阶用法,它需要配合条件语句一起使用:

A extends B ? C : D

含义是:如果类型 A 可以赋值给类型 B,则结果为 C,否则为 D。

infer 的作用是在条件类型的 extends 子句(也就是 B 语句)中 声明一个待推断的类型变量。可以把它理解为"占位符",让 TypeScript 帮你从某个复杂类型中"提取"出一部分。当类型匹配成功时,infer 声明的类型变量会被推断为匹配到的具体类型。

举一些实用的例子:

// 获取 Promise 返回值的类型
type UnpackPromise<T> = T extends Promise<infer R> ? R : T
type P1 = UnpackPromise<Promise<string>> // string

// 获取数组中元素类型
type GetArrayType<T> = T extends (infer U)[] ? U : never
type A1 = GetArrayType<number[]> // number

// 获取函数的返回值类型
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type R1 = GetReturnType<() => boolean> // boolean

2. 在模板字符串类型中使用 infer

在模板字符串中 infer 的使用方法同上,也需要配合条件语句,区别是我们需要通过 ${infer T} 把它插入到模板字符串类型中,通过模式匹配来提取字符串的特定部分。

比如提取出字符串类型空格后面的部分。

type GetSurname<S> = S extends `${infer First} ${infer Last}` ? Last : never

type T1 = GetSurname<'Hello Cookie'> // "Cookie"
type T2 = GetSurname<'Cookie'> // never (因为没有空格,匹配失败)

3. 贪婪与非贪婪匹配

多个 infer 连用,TypeScript 使用 “非贪婪” 匹配策略,也就是会遵循 “从左到右,尽可能匹配最小单位” 的原则。

比如,当使用 ${infer A}${infer B} 时:

  • A(第一个 infer):匹配第一个字符(最短匹配)
  • B(第二个 infer):匹配剩余所有字符(由于 A 已经匹配了第一个字符,B 必须匹配剩余的全部内容)

举例说明:

// 三个连续的 infer 会依次匹配:A 匹配第一个字符,B 匹配第二个字符,C 匹配剩余所有字符
type Split<S> = S extends `${infer A}${infer B}${infer C}` ? [A, B, C] : []
type Res = Split<'Hello'> // ["H", "e", "llo"]

// 有占位符的情况,也只会匹配到第一个
type GetExt<S> = S extends `${infer Name}.${infer Ext}` ? Ext : never
type E2 = GetExt<'ts.config.json'> 
// "config.json"(Name 匹配到第一个点之前,Ext 获取之后的所有内容)

4. infer 与联合类型联动

当模式中包含联合类型时,TypeScript 会尝试匹配联合类型中的任意一个成员:

// 提取指定几种扩展类型的文件名
type ImageExt = 'jpg' | 'png' | 'gif' | 'webp'
type ExtractRawName<FileName> = FileName extends `${infer Name}.${ImageExt}`
  ? Name
  : never

type E1 = ExtractRawName<'photo.jpg'> // "photo"
type E2 = ExtractRawName<'logo.png'> // "logo"
type E3 = ExtractRawName<'document.pdf'> // never (pdf 不在 ImageExt 联合类型中)

TypeScript 会对联合类型中的每一个成员分别执行 infer 逻辑,最后再把结果重新组合成一个新的联合类型,在模板字符串中也一样。

type IconNames = 'icon-home' | 'icon-user' | 'icon-settings' | 'logo-main'

// 提取所有以 "icon-" 开头的图标名称
type ExtractIcon<T> = T extends `icon-${infer Name}` ? Name : never

type PureNames = ExtractIcon<IconNames>
// 结果: "home" | "user" | "settings"
// 注意: "logo-main" 匹配失败,返回 never,在联合类型中 never 会被自动过滤掉

5. 递归类型 (Recursive Types)

在上一节的基础上,我们再结合递归,可以更加灵活的处理字符串,接下来以 TrimLeft 举例:

目标:去除字符串左边的空格。

type Space = ' ' | '\n' | '\t' // 联合类型 包含三种空白字符

// 如果第一个字符是空白字符,就取除了第一个空白字符的剩余字符串,然后递归处理
// 否则直接取整个字符
type TrimLeft<S extends string> = S extends `${Space}${infer R}`
  ? TrimLeft<R> // 递归调用
  : S // 终止

type T = TrimLeft<'  cookie'> // "cookie"

如果你在纠结为什么不是 \s 而是 ' '?这是因为 TypeScript 的模板字符串类型不支持正则表达式语法。这里的 ' ''\n''\t' 都是具体的字符串字面量类型,而 \s 是正则表达式的特殊语法,在类型系统中没有意义。

四、 映射类型与模板字符串的结合

TypeScript 4.1 不仅引入了模板字符串类型,还支持在映射类型中使用 as 重命名键名。

1. as 语法

type MappedType<T> = {
  [K in keyof T as NewKeyType<K>]: T[K]
}

在之前《面试官:请实现 TS 中的 Pick 和 Omit》一文中,在 Omit 的实现中就用到了 as 来剔除一些类型:

type MyOmit<T, K> = {
  [P in keyof T as P extends K ? never : P]: T[P];
};

type Todo = {
  title: string
  description: string
  completed: boolean
}
type TodoWithoutDescription = MyOmit<Todo, 'description'>
/*
type TodoWithoutDescription = {
  title: string
  completed: boolean
}
*/

2. 在模板字符串中使用

示例 1:添加前缀/后缀

type AddPrefix<T, Prefix extends string> = {
  [K in keyof T as `${Prefix}${string & K}`]: T[K]
}

interface User {
  name: string
  age: number
}

type PrefixedUser = AddPrefix<User, 'user_'>
// { user_name: string; user_age: number }

为什么要 string & K

因为 K 的类型是 keyof T,可能是 string | number | symbol。用交叉类型 & 将其约束为 string

示例 2:生成 Getter/Setter

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (val: T[K]) => void
}

interface State {
  count: number
  name: string
}

type StateGetters = Getters<State>
// { getCount: () => number; getName: () => string }

type StateSetters = Setters<State>
// { setCount: (val: number) => void; setName: (val: string) => void }

示例 3:移除特定前缀

type RemovePrefix<T, Prefix extends string> = {
  [K in keyof T as K extends `${Prefix}${infer Rest}` ? Rest : K]: T[K]
}

interface ApiResponse {
  api_name: string
  api_token: string
  userId: number
}

type CleanResponse = RemovePrefix<ApiResponse, 'api_'>
// { name: string; token: string; userId: number }

// 注意:传入空字符串作为前缀时,由于空字符串会匹配所有键名,但实际上不会移除任何内容
type CleanResponse1 = RemovePrefix<ApiResponse, ''>
// { api_name: string; api_token: string; userId: number }

五、 总结

写这篇文章因为我在刷 TypeScript 类型体操 时,遇到了第一个模板字符串类型的题目 TrimLeft 搜了半天没有发现现成的文章,干脆自己写一个。

如果你也对 TypeScript 类型体操感兴趣,欢迎一起来刷!💪🏻💪🏻💪🏻

❌
❌