TypeScript 模板字面量类型在真实项目中的应用

HTMLPAGE 团队
17 分钟阅读

模板字面量类型让 TypeScript 的类型系统从

#TypeScript #模板字面量类型 #类型安全 #字符串处理 #高级类型

模板字面量类型是 TypeScript 4.1 引入的一个能力。它的语法和 JavaScript 模板字符串一样——用反引号和 ${} 插值——但运行在类型空间而不是值空间。它不是运行时拼接字符串,而是在编译期约束字符串的格式。

大多数人对模板字面量类型的理解停留在"可以做 ${string} 匹配",但实际它有三个更重要的能力:联合展开、模式匹配(配合 infer)和大小写变换。

基础:模板字面量的联合展开

模板字面量类型被联合类型插值时,会自动展开为所有组合:

type Size = 'small' | 'medium' | 'large'
type Color = 'red' | 'blue'

type ButtonClass = `btn-${Size}-${Color}`
// "btn-small-red" | "btn-small-blue" | "btn-medium-red"
// | "btn-medium-blue" | "btn-large-red" | "btn-large-blue"

这看起来像字符串拼接,实际是笛卡尔积:每个插值位置的联合类型都会和其他位置做排列组合。3 × 2 = 6 个结果。如果每个位置都是宽类型(如 string),结果只会是 string——因为 string 不是联合类型,没有展开的行为。

更实用的场景是自动生成 CSS 类名或样式键:

type Spacing = '0' | '1' | '2' | '3' | '4'
type Side = 't' | 'b' | 'l' | 'r'

type MarginClass = `m${Side}-${Spacing}`
// "mt-0" | "mt-1" | "mt-2" | ... | "mb-0" | ...

模式匹配:模板字面量 + infer 的组合

模板字面量真正的价值是和 infer 配合做字符串模式匹配。它允许类型根据字符串格式推断出变量部分:

type ExtractVersion<T extends string> =
  T extends `v${infer V}` ? V : never

type V1 = ExtractVersion<'v1.0.0'>  // "1.0.0"
type V2 = ExtractVersion<'2.0.0'>   // never

这种模式不只是字符串切片——它在类型层面做了解析。来看一个更复杂的情况:从 API 路径中提取参数名。

假设你的路由模式是 /users/:id/posts/:postId,你想从路径字符串中提取所有以 : 开头的参数名:

type ExtractRouteParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ExtractRouteParams<Rest>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never

type Params = ExtractRouteParams<'/users/:id/posts/:postId'>
// "id" | "postId"

这个递归类型的机制:

  1. 从左侧查找 : 模式,提取 : 后的参数名直到下一个 /
  2. 递归处理剩余部分
  3. 当不再匹配 : 模式时终止

实战 1:类型安全的 API 客户端

以下是一个利用模板字面量类型做类型安全 API 调用的模式:

type Methods = 'GET' | 'POST' | 'PUT' | 'DELETE'

type ApiPath<M extends Methods, P extends string> =
  `${M} /api/${P}`

type GetUser = ApiPath<'GET', 'users/:id'>
// "GET /api/users/:id"

type CreateUser = ApiPath<'POST', 'users'>
// "POST /api/users"

配合函数重载,可以把 API 路径变成类型安全的调用:

type Routes = {
  'GET /api/users': { response: User[] }
  'GET /api/users/:id': { params: { id: string }; response: User }
  'POST /api/users': { body: CreateUserInput; response: User }
}

type ExtractParams<T extends string> =
  T extends `${string}:${infer P}/${string}`
    ? P | ExtractParams<T>
    : T extends `${string}:${infer P}`
      ? P
      : never

async function apiCall<R extends keyof Routes>(
  route: R,
  ...args: ExtractParams<R> extends never
    ? []
    : [params: Record<ExtractParams<R>, string>]
): Promise<Routes[R]['response']> {
  // 实现...
}

// 使用
const users = await apiCall('GET /api/users')
const user = await apiCall('GET /api/users/:id', { id: '123' })

实战 2:事件名称的类型安全

在事件驱动的架构里,事件名称通常是字符串常量。模板字面量类型可以自动生成事件名组合:

type Domain = 'user' | 'order' | 'payment'
type Action = 'created' | 'updated' | 'deleted'

type EventName = `${Domain}:${Action}`
// "user:created" | "user:updated" | ... | "payment:deleted"

type EventMap = {
  [K in EventName]: {
    domain: K extends `${infer D}:${string}` ? D : never
    action: K extends `${string}:${infer A}` ? A : never
    timestamp: number
    data: unknown
  }
}

实战 3:CSS 属性值的类型校验

如果你写过样式相关的工具函数,知道 CSS 值很多是有固定格式的:

type CSSLength = `${number}${'px' | 'em' | 'rem' | '%' | 'vh' | 'vw'}`
type CSSColor = `#${string}` | `rgb(${number},${number},${number})`

function setStyle(prop: string, value: CSSLength | CSSColor) {
  // 只接受合法的长度和颜色值
}

setStyle('width', '100px')     // ✅
setStyle('width', '50%')       // ✅
setStyle('width', 'auto')      // ❌ —— auto 不是合法长度

大小写变换工具类型

TypeScript 内置了四个大小写辅助类型,常和模板字面量一起用:

type EventName = 'user-login' | 'user-logout' | 'order-paid'

// 驼峰转换
type ToCamel<S extends string> =
  S extends `${infer P}-${infer Q}`
    ? `${P}${Capitalize<ToCamel<Q>>}`
    : S

type CamelEvents = ToCamel<EventName>
// "userLogin" | "userLogout" | "orderPaid"

// 事件监听器类型
type EventListenerMap = {
  [K in EventName as `on${Capitalize<ToCamel<K>>}`]: () => void
}
// { onUserLogin: () => void; onUserLogout: () => void; ... }

Capitalize 是四个内置类型之一,其他三个是 UncapitalizeUppercaseLowercase。这些只对 ASCII 字母有效,不影响非字母字符。

模板字面量的性能考量

模板字面量类型在联合展开时会产生组合爆炸。如果每个插值位置有 10 个联合成员,3 个插值位置就会产生 1000(10³)个类型实例。虽然 TypeScript 会缓存已实例化的类型,但 1000+ 规模的联合仍然会影响编辑器的自动补全速度。

type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl'          // 5
type Color = 'red' | 'blue' | 'green' | 'yellow'        // 4
type Variant = 'solid' | 'outline' | 'ghost'            // 3

type AllCombos = `btn-${Size}-${Color}-${Variant}`
// 5 × 4 × 3 = 60 —— 可接受

// 如果要增加到更多维度,观察编辑器的响应速度

如果发现编辑器在补全联合类型时明显卡顿,说明类型实例化数量超过了阈值。考虑分拆成更小的联合或限制数量。

总结

模板字面量类型最有价值的三种用法:

用法场景示例
联合展开生成类名、事件名、路径\btn-${Size}-${Color}``
infer 模式匹配提取路径参数、解析事件\${string}:${infer P}/${string}``
大小写变换命名转换、事件适配Capitalize<ToCamel<K>>

模板字面量类型的适用边界很清晰:当你的字符串有固定格式但内容可变时,用类型去约束它比运行时校验更早发现问题。但如果字符串格式太灵活,比如自然语言或自由文本,模板字面量类型就帮不上忙了——它只知道格式,不懂语义。