第 10 章
模板字面量类型:类型级字符串运算
第10章:模板字面量类型:类型级字符串运算
理解模板字面量类型是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用类型级字符串运算?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 基础语法
- 与联合类型组合:笛卡尔积
- 内置字符串工具类型
Level 1 · 你需要知道的(1-3年经验)
基础语法
模板字面量类型的语法与 JavaScript 模板字符串完全相同,只是操作对象变成了类型。
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, World"; // ✓
const b: Greeting = "Hello, Alice"; // ✓
const c: Greeting = "Hi, Alice"; // ✗ 不匹配模式
拼接具体的字符串字面量类型:
type Domain = "com" | "net" | "org";
type Protocol = "http" | "https";
type URL = `${Protocol}://${string}.${Domain}`;
const valid: URL = "https://example.com"; // ✓
const invalid: URL = "ftp://example.com"; // ✗ ftp 不在 Protocol 中
与联合类型组合:笛卡尔积
当模板字面量的插槽是联合类型时,TypeScript 自动计算所有组合。
type Direction = "top" | "right" | "bottom" | "left";
type Axis = "X" | "Y";
type ScrollProperty = `scroll${Axis}`;
// "scrollX" | "scrollY"
type MarginProperty = `margin${Capitalize<Direction>}`;
// "marginTop" | "marginRight" | "marginBottom" | "marginLeft"
// 多个联合类型:所有组合
type Size = "sm" | "md" | "lg";
type Color = "primary" | "secondary";
type ButtonVariant = `${Color}-${Size}`;
// "primary-sm" | "primary-md" | "primary-lg"
// | "secondary-sm" | "secondary-md" | "secondary-lg"
内置字符串工具类型
TypeScript 提供四个字符串操作工具类型,专为模板字面量设计:
type S = "helloWorld";
type U = Uppercase<S>; // "HELLOWORLD"
type L = Lowercase<S>; // "helloworld"
type C = Capitalize<S>; // "HelloWorld"
type N = Uncapitalize<S>; // "helloWorld" (首字母小写)
// 典型用法:生成驼峰命名
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
用 infer 解析字符串字面量
模板字面量可以配合 infer 拆解字符串,提取其中的部分。
// 提取前缀之后的部分
type ExtractAfterOn<T extends string> = T extends `on${infer Event}`
? Event
: never;
type A = ExtractAfterOn<"onClick">; // "Click"
type B = ExtractAfterOn<"onFocus">; // "Focus"
type C = ExtractAfterOn<"resize">; // never
// 从点号分隔的字符串提取第一段
type Head<T extends string> = T extends `${infer H}.${string}` ? H : T;
type Tail<T extends string> = T extends `${string}.${infer R}` ? R : never;
type H = Head<"user.name">; // "user"
type R = Tail<"user.name">; // "name"
Level 2 · 它是怎么运行的(3-5年经验)
实战 1:类型安全的 CSS 属性名
type CSSProperty =
| "background-color"
| "border-radius"
| "font-size"
| "margin-top"
| "padding-left";
// 将 kebab-case 转为驼峰(简化版,实际需递归处理多个连字符)
type KebabToCamel<T extends string> =
T extends `${infer Head}-${infer Tail}`
? `${Head}${Capitalize<KebabToCamel<Tail>>}`
: T;
type CamelCSSProperty = KebabToCamel<CSSProperty>;
// "backgroundColor" | "borderRadius" | "fontSize" | "marginTop" | "paddingLeft"
// 构建类型安全的样式对象
type StyleObject = Partial<Record<CamelCSSProperty, string>>;
function applyStyle(element: HTMLElement, styles: StyleObject) {
for (const [key, value] of Object.entries(styles)) {
(element.style as Record<string, string>)[key] = value ?? "";
}
}
applyStyle(document.body, {
backgroundColor: "#fff", // ✓
borderRadius: "8px", // ✓
// backgroundColour: "#fff" // ✗ 拼写错误立即报错
});
实战 2:事件系统的类型安全处理器名
interface DOMEvents {
click: MouseEvent;
focus: FocusEvent;
blur: FocusEvent;
keydown: KeyboardEvent;
resize: UIEvent;
}
// 生成 on + 首字母大写 的处理器签名
type EventListeners = {
[K in keyof DOMEvents as `on${Capitalize<K>}`]: (event: DOMEvents[K]) => void;
};
// { onClick: (event: MouseEvent) => void; onFocus: (event: FocusEvent) => void; ... }
// 反向:从处理器名推断事件名
type ExtractEvent<T extends string> = T extends `on${infer E}`
? Uncapitalize<E>
: never;
type E = ExtractEvent<"onClick">; // "click"
// 构建带类型约束的事件注册函数
function addEventListener<K extends keyof DOMEvents>(
element: HTMLElement,
event: K,
handler: (e: DOMEvents[K]) => void
): void {
element.addEventListener(event, handler as EventListener);
}
addEventListener(document.body, "click", (e) => {
// e 被推断为 MouseEvent,有 clientX、clientY 等
console.log(e.clientX, e.clientY);
});
实战 3:i18n 键类型安全
// 定义翻译文件的结构类型
interface Translations {
home: {
title: string;
subtitle: string;
cta: string;
};
about: {
title: string;
team: string;
};
errors: {
notFound: string;
serverError: string;
};
}
// 递归生成所有点号路径(如 "home.title", "about.team")
type DotPaths<T, Prefix extends string = ""> = {
[K in keyof T & string]: T[K] extends object
? DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string];
type TranslationKey = DotPaths<Translations>;
// "home.title" | "home.subtitle" | "home.cta"
// | "about.title" | "about.team"
// | "errors.notFound" | "errors.serverError"
function t(key: TranslationKey): string {
// 实现略,类型已保证键合法
return key;
}
t("home.title"); // ✓
t("about.team"); // ✓
// t("home.missing"); // ✗ 类型错误,键不存在
// t("HOME.TITLE"); // ✗ 大小写必须精确匹配
实战 4:Express 风格路由参数提取
// 从路由字符串提取所有 :param 参数名
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params1 = ExtractRouteParams<"/users/:id">;
// "id"
type Params2 = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
type Params3 = ExtractRouteParams<"/users/:userId/posts/:postId/comments/:commentId">;
// "userId" | "postId" | "commentId"
// 构建类型安全的路由处理器
type RouteHandler<Path extends string> = (
params: Record<ExtractRouteParams<Path>, string>
) => void;
function defineRoute<Path extends string>(
path: Path,
handler: RouteHandler<Path>
) {
// 注册路由逻辑(省略)
return { path, handler };
}
defineRoute("/users/:userId/posts/:postId", (params) => {
// params.userId ✓ —— 类型推断正确
// params.postId ✓
// params.postIdx ✗ —— 不存在的参数,编译报错
console.log(params.userId, params.postId);
});
组合爆炸问题
模板字面量类型的笛卡尔积可能产生数量庞大的联合类型,影响编译性能。
// ✗ 危险:6 × 6 × 10 = 360 种组合,还在可接受范围
type Size = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
type Color = "red" | "green" | "blue" | "yellow" | "purple" | "gray";
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950";
type TailwindColor = `${Color}-${Weight}`; // 60 种
type TailwindFull = `${Size}:${TailwindColor}`; // 360 种
// ✗ 极度危险:26 × 26 × 26 = 17576 种组合
type Letter = "a" | "b" | "c" /* ... */ | "z";
type ThreeLetterCode = `${Letter}${Letter}${Letter}`; // 不要这样做!
判断标准:联合类型组合超过 1000 种时应重新设计,改用 string 配合运行时验证。
// ✓ 改用 string + 运行时验证
function setTailwindClass(className: string): void {
if (!isValidTailwindClass(className)) {
throw new Error(`Invalid class: ${className}`);
}
// ...
}
对比表
| 用途 | 工具 | 示例 |
|---|---|---|
| 拼接字符串类型 | `${A}${B}` |
"get" + "User" → "getUser" |
| 首字母大写 | Capitalize<S> |
"click" → "Click" |
| 全部大写 | Uppercase<S> |
"get" → "GET" |
| 提取子串 | infer in template |
"onClick" → "Click" |
| 联合类型展开 | 插入联合类型 | "a"|"b" → "xa"|"xb" |
| 路由参数提取 | 递归 infer | ":id" → "id" |
| i18n 路径生成 | 递归 DotPaths | 接口 → 所有点号路径 |
Level 3 · 规范怎么定义的(资深)
模板字面量类型(TS 4.1)在类型层面实现了字符串模式匹配,其底层使用了与 JavaScript 模板字符串相同的语法。联合类型在模板插槽中产生笛卡尔积是分布式求值的自然结果。infer 在模板字面量中可以提取子串,这使得从路由字符串自动提取参数名成为可能。但需注意组合爆炸问题:超过约 1000 种组合时编译器性能急剧下降,此时应退回到 string + 运行时验证。
Level 4 · 边界与陷阱(所有人)
反模式对比
// ❌ 反模式:用模板字面量类型替代简单的字符串枚举
type Endpoint = `${"GET" | "POST" | "PUT" | "DELETE"} ${string}`;
// 无法精确匹配路径,string 太宽泛,失去类型安全
// ✓ 正确:具体的路由类型 + 独立的方法类型
type Method = "GET" | "POST" | "PUT" | "DELETE";
type KnownPath = "/users" | "/posts" | "/comments";
interface Request {
method: Method;
path: KnownPath;
}
// ❌ 反模式:infer 嵌套超过两层,可读性极差
type DeepExtract<T extends string> =
T extends `${infer A}_${infer B}_${infer C}_${infer D}`
? [A, B, C, D]
: T extends `${infer A}_${infer B}_${infer C}`
? [A, B, C]
: T extends `${infer A}_${infer B}`
? [A, B]
: [T];
// 可以工作,但改用字符串分割函数 + 运行时处理更清晰
本章小结
| 概念 | 关键点 |
|---|---|
| 基础语法 | 和 JS 模板字符串相同,操作类型而非值 |
| 联合类型展开 | 自动计算笛卡尔积,注意组合数量 |
| 字符串工具类型 | Uppercase / Lowercase / Capitalize / Uncapitalize |
infer 提取 |
从字符串模式中反向提取子串 |
| 路由参数 | 递归 infer 提取所有 :param |
| i18n 键 | 递归 DotPaths 生成点号路径联合类型 |
| 组合爆炸 | 超过 1000 种组合时改用 string + 运行时验证 |
下一章预告
第 11 章讲型变——协变、逆变、不变。你将彻底理解为什么 (a: Animal) => void 可以赋值给 (a: Dog) => void,以及为什么 Array<Dog> 不能赋值给 Array<Animal>,这是理解泛型安全性的核心。