你不知道的 TypeScript:模板字符串类型
大部分前端应该都或多或少地使用过 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
掌握了基础的模板字符串拼接后,接下来我们进入更强大的领域——模式匹配。要在类型中解析字符串(例如提取参数、去掉空格),我们需要结合 extends 和 infer。
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 类型体操感兴趣,欢迎一起来刷!💪🏻💪🏻💪🏻