第 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>,这是理解泛型安全性的核心。

本章评分
4.5  / 5  (34 评分)

💬 留言讨论