文章

模板字面量类型深度解析

模板字面量类型让 TypeScript 能在类型层面拼接、匹配、解析字符串,是路由参数、国际化 key、CSS 属性值的精确约束利器。 核心是占位符的笛卡尔积展开,配合 Uppercase、Capitalize 等内置工具与 infer 做模式匹配,告别运行时正则。

模板字面量类型深度解析

一句话概括

模板字面量类型让 TypeScript 的类型系统能”算字符串”——${Prefix}${Name} 在类型层面拼接、匹配、解析,从此路由参数、国际化 key、CSS 属性值都能在编译期被精确约束,告别运行时正则 + 祈祷。

核心知识点

1. 基础:类型空间的模板字符串拼接

1
2
3
4
5
6
7
8
9
10
// 联合类型占位符 → 自动笛卡尔积展开
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

// 多联合类型 → 所有组合全枚举
type Lang = "zh" | "en";
type Region = "CN" | "US";
type Locale = `${Lang}-${Region}`;
// "zh-CN" | "zh-US" | "en-CN" | "en-US"(2×2=4 种)

⚠️ 规模警告:5 个各含 20 个成员的联合组合 = 3,200,000 种排列,超出 TS 编译器限制(~100K),会报 union too complex。日常 3-4 个属性的组合完全没问题。

2. 四个内置字符串工具类型

1
2
3
4
5
6
7
type A = Uppercase<"hello">;      // "HELLO"
type B = Lowercase<"Hello">;      // "hello"
type C = Capitalize<"hello">;     // "Hello"
type D = Uncapitalize<"Hello">;   // "hello"

// 这些是 TS 编译器内置的字符串操作,不是运行时的 .toUpperCase()
// 编译后完全消失,零运行时开销

3. infer + 模板字面量 = 字符串模式匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 提取路由前缀后的路径
type RoutePath<T extends string> =
  T extends `/api/${infer P}` ? P : never;
type P = RoutePath<"/api/users">; // "users"

// 提取域名
type Host<T extends string> =
  T extends `https://${infer H}/${string}` ? H : never;
type H = Host<"https://api.github.com/v3/repos">; // "api.github.com"

// 提取文件扩展名
type Ext<T extends string> =
  T extends `${string}.${infer E}` ? E : never;
type E = Ext<"index.ts">; // "ts"

4. 递归模板字面量:KebabCase / Split

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// HelloWorld → hello-world
type KebabCase<S extends string> =
  S extends `${infer First}${infer Rest}`
    ? Rest extends Uncapitalize<Rest>
      ? `${Lowercase<First>}${KebabCase<Rest>}`     // 小写字母,继续
      : `${Lowercase<First>}-${KebabCase<Rest>}`    // 大写字母,插入分隔符
    : S;

type K = KebabCase<"HelloWorld">; // "hello-world"

// 按分隔符拆成联合类型
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? Head | Split<Tail, D>
    : S;

type Parts = Split<"a,b,c", ",">; // "a" | "b" | "c"

递归限制:TS 类型递归深度约 50 层,处理短字符串(路由、变量名)无压力,处理 100 字符的 URL 可能超限。

5. 实战:路由参数类型安全提取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 从路由模式字符串提取参数对象类型
type RouteParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & RouteParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type Params = RouteParams<"/user/:userId/post/:postId">;
// { userId: string } & { postId: string }
// → 等价于 { userId: string; postId: string }

// 实战使用:
// useParams<"/user/:id">() 返回 { id: string }
// react-router v6 / vue-router 4 底层就用类似技术做类型推导

其实你每天都在用

  1. Redux action type 生成:`${slice}/INCREMENT` — 模板字面量类型保证 action type 不会拼错
  2. CSS 单位约束:type Size = \${number}px` | `${number}rem` — 输入 “10px” 没问题,输 “10”` 报错
  3. 国际化 key 类型:`${Section}.${Key}` — 防止写成 "header.titel"(拼错)而不是 "header.title"
  4. 事件名约束:on${EventName} — 确保 onClick/onFocus 没有不存在的事件名
  5. Tailwind 类名类型:`bg-${Color}-${Shade}` — 限制类名组合只能是已定义的 class

常见误解(FAQ)

❌ 误区 1:「${string} 等于 string」

${string} 只能作为模板字面量类型中的”通配符”使用。/api/${string} 匹配 /api/users、/api/v2/list,但不匹配 /api 或 /user/api。它是一个嵌在上下文中的模式匹配符,不能独立表示”任意字符串”。

❌ 误区 2:「模板字面量里的联合类型组合无限安全」

笛卡尔积会爆炸。A | B | C(3个)× X | Y | Z(3个)× 1 | 2 | 3(3个)= 27 种没问题。但 5 个维度各 20 个值 = 3.2M 种 → TS 直接拒绝计算。遇到 union too complex 错误先检查模板字面量的联合规模。

❌ 误区 3:「递归模板字面量可以处理任意长度的字符串」

TS 类型递归 ~50 层限制,处理路由路径(通常 ≤ 10 段)完全够用,但不能拿来处理长 URL 或大段文本。递归模板字面量本质是”玩具”——在可控输入上很强大,在无界输入上碰到递归深度限制就露馅了。

❌ 误区 4:「模板字面量类型等于 JS 的模板字符串」

一个是类型空间(编译时),一个是值空间(运行时)。语法相同(反引号 + ${}),但类型空间的 Uppercase<"hello"> 编译后无任何代码残留,值空间的 `hello ${name}` 编译后是 JS 字符串拼接。不要混淆两者的使用场景和能力。

一句话总结

模板字面量类型让 TS 从”检查字符串”进化到”理解字符串”——从此路由解析、key 校验、单位约束不再靠运行时抛异常,而是写完代码就红线提醒。

本文由作者按照 CC BY 4.0 进行授权