《TypeScript编程入门》10.2 模板字面量类型

本节讲解模板字面量类型如何把字符串拼接搬进类型系统:用反引号加 ${} 构造字面量模式,与联合类型组合时自动展开成笛卡尔积,配合 Uppercase 等内置工具与 infer 实现字符串解析、Trim、Split、Replace 等递归算法。最后落到类型安全的事件名、路由参数与 i18n 键三个真实场景,并说明贪婪匹配、无法反向推断与编译性能这三类坑。

本节目标:读完这一节,你能用反引号加 ${} 写出模板字面量类型并预测它的展开结果;能说清它与联合类型组合时的笛卡尔积规则;能用 infer 把字符串「拆开」,实现提取路由参数、Trim、Split 这类字符串级算法;并知道模板字面量类型在什么情况下会让编译变慢、什么情况下无法由结果反推输入。

10.2 模板字面量类型

上一节我们看到 Capitalize<string & K> 能把 x 变成 GetX。这个能力背后是本节的主角——模板字面量类型(Template Literal Types)。

JavaScript 里我们用反引号拼字符串:

const name = "Ada";
const greeting = `hello, ${name}!`;   // "hello, Ada!"

TypeScript 4.1 把同样的语法搬到了类型层面:

type Greeting = `hello, ${string}!`;

const ok: Greeting = "hello, Ada!";    // ✅
const bad: Greeting = "hi, Ada!";      // ❌
// 类型 '"hi, Ada!"' 不能赋值给类型 '`hello, ${string}!`'。ts(2322)

也就是说,模板字面量类型是一个「字符串模式」:只要满足这个形状的字符串字面量,就是合法值。这是 TypeScript 里少有的、能直接约束「字符串长什么样」的机制。

基础:占位符可以是哪些类型

占位符位置能放的不只是 string,还可以是字面量、number、bigint、boolean、null、undefined,以及它们的联合:

type Id = `id-${number}`;
type Flag = `is-${boolean}`;
type Env = `env:${"dev" | "prod"}`;

const a: Id = "id-42";        // ✅
const b: Flag = "is-true";    // ✅
const c: Env = "env:prod";    // ✅
const d: Env = "env:test";    // ❌

反过来说,如果占位符是纯 string,那这个类型基本不产生约束——hello, ${string}! 只保证前缀和后缀。真正有区分度的是「字面量 + 字面量联合」。

与联合类型组合:自动展开成笛卡尔积

当占位符是联合类型时,模板会自动对每个成员展开,把所有组合枚举出来:

type Size = "small" | "large";
type Color = "red" | "blue";

type Combo = `${Size}-${Color}`;
// "small-red" | "small-blue" | "large-red" | "large-blue"

两个占位符各 2 个成员,结果就是 2 × 2 = 4 个成员。组合数是各位置成员数的乘积,这一点是后面「联合爆炸」风险的根源。

配合内置的字符串工具,可以批量生成命名风格转换:

type Keys = "name" | "age" | "email";

type Getters = `get${Capitalize<Keys>}`;
// "getName" | "getAge" | "getEmail"

type Setters = `set${Capitalize<Keys>}`;
// "setName" | "setAge" | "setEmail"

infer:把字符串「拆开」

模板字面量类型最实用的地方,是它可以出现在条件类型的 extends 右侧当模式,用 infer 把片段抠出来:

type ExtractName<T> = T extends `hello, ${infer Name}!` ? Name : never;

type N1 = ExtractName<"hello, Ada!">;   // "Ada"
type N2 = ExtractName<"hello, Bob!">;   // "Bob"
type N3 = ExtractName<"hi, Ada!">;      // never

注意这里的匹配是非贪婪的:${infer A}${infer B} 中,A 会尽可能短。

type Split2<S> = S extends `${infer A}${infer B}` ? [A, B] : never;

type R = Split2<"abc">;
// ["a", "bc"]  ← A 只拿到 1 个字符,不是 ["ab", "c"]

想拿到固定长度的前缀,要显式写死分隔符或长度:

type Version = `${number}.${number}.${number}`;

type IsSemver<T> = T extends Version ? true : false;
type V1 = IsSemver<"1.2.3">;   // true
type V2 = IsSemver<"1.2">;     // false

实战一:提取路由参数

这是模板字面量类型最经典的用法。给定一条路由字符串,把 :param 形式的参数名全部提出来:

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

type P1 = ExtractRouteParams<"/users/:userId">;
// "userId"

type P2 = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

这段代码有四个值得注意的细节:

  1. 第一个分支匹配「:参数 后面还有 /」的情况,说明参数名后面还有更多路径,于是递归处理剩下的部分。
  2. 递归时把 Rest 重新拼上前导 /(`/${Rest}`),是为了让下一轮的 `${string}:${infer Param}` 模式能正确对齐。
  3. 第二个分支是终止条件:只剩一个 :参数 且后面没有 / 了。
  4. 两个分支的结果用 | 合并,于是所有参数名汇聚成一个联合类型。

有了参数名,就能进一步推导出「路径 → 参数对象」的映射:

type RouteParams<T extends string> = {
  [K in ExtractRouteParams<T>]: string;
};

type Params = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }

这正是现代类型安全路由库(如类型化 React Router 封装)的内部原理:路径字符串本身就是类型的唯一真实来源。

实战二:类型安全的 i18n 键

多语言项目里,t("user.name") 这类调用最怕拼错键名。用模板字面量类型可以把层级结构表达出来:

type Messages = {
  user: {
    name: string;
    email: string;
  };
  post: {
    title: string;
    body: string;
  };
};

type DotPath<T> = {
  [K in keyof T & string]: T[K] extends object
    ? `${K}` | `${K}.${DotPath<T[K]>}`
    : `${K}`;
}[keyof T & string];

type MessageKey = DotPath<Messages>;
// "user" | "user.name" | "user.email" | "post" | "post.title" | "post.body"

declare function t(key: MessageKey): string;

t("user.name");    // ✅
t("user.nmae");    // ❌ 参数类型 '"user.nmae"' 不能赋值给 'MessageKey'。ts(2345)

DotPath 里的 keyof T & string 是一个常见手法:keyof T 的类型是 string | number | symbol,而模板字面量占位符要求 string,用交叉类型先收窄,才能安全地插进模板。

递归字符串算法:Trim / Split / Replace

模板字面量类型配合递归,可以实现一整套「类型层面的字符串处理」。这在解析用户提供的字面量配置时偶尔有用。

Trim 去空白:

type Whitespace = " " | "\n" | "\t";

type TrimLeft<S extends string> =
  S extends `${Whitespace}${infer Rest}` ? TrimLeft<Rest> : S;

type TrimRight<S extends string> =
  S extends `${infer Rest}${Whitespace}` ? TrimRight<Rest> : S;

type Trim<S extends string> = TrimLeft<TrimRight<S>>;

type T1 = Trim<"   hello   ">;   // "hello"

Split 拆分:

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"]

type PathSegments = Split<"users/42/posts", "/">;
// ["users", "42", "posts"]

ReplaceAll 替换:

type ReplaceAll<
  S extends string,
  From extends string,
  To extends string
> = From extends ""
  ? S
  : S extends `${infer Head}${From}${infer Tail}`
    ? `${Head}${To}${ReplaceAll<Tail, From, To>}`
    : S;

type Kebab = ReplaceAll<"userNameIsLong", "N", "-n">;
// 注意:这里只替换了单个字符 N,结果为 "user-nameIsLong"

第三个例子顺便说明了一件事:ReplaceAll 替换的是字面量子串,不是正则。想按驼峰转 kebab 这种语义规则转换,模板字面量类型做不到——它没有正则能力。

实战三:类型安全的事件系统

把映射类型的键重映射、Capitalize 与模板字面量组合起来,可以从一张事件表自动生成处理器接口:

interface EventMap {
  click: MouseEvent;
  keydown: KeyboardEvent;
  submit: SubmitEvent;
}

type Handlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]: (event: E[K]) => void;
};

type AppHandlers = Handlers<EventMap>;
// {
//   onClick: (event: MouseEvent) => void;
//   onKeydown: (event: KeyboardEvent) => void;
//   onSubmit: (event: SubmitEvent) => void;
// }

const handlers: AppHandlers = {
  onClick: (e) => console.log(e.clientX),      // e 自动推导为 MouseEvent
  onKeydown: (e) => console.log(e.key),
  onSubmit: (e) => e.preventDefault(),
};

onClick 的参数类型是编译器推导出来的,不是手写的。这种「从一个来源派生另一套命名空间」的模式,是模板字面量类型最有价值的落地场景。

常见坑与错误信息

坑一:${number} 比想象中宽松。 它匹配任何「看起来像数字字面量」的字符串,包括负数、小数、指数:

type Num = `n${number}`;
const a: Num = "n-1.5e3";   // ✅ 合法
const b: Num = "n0x10";     // ❌ 不匹配十六进制
const c: Num = "nInfinity"; // ❌ 不匹配 Infinity

TypeScript 4.8 收紧了 number 在模板中的匹配规则,但负数与小数依然通过。需要严格的整数校验时,模板字面量类型不够用,得上运行时校验。

坑二:无法由结果反向推断输入。 模板字面量类型是单向的:能从 "hello, Ada!" 推出 Ada,但不能在泛型推导里「由模板结果反推参数」。下面这种写法不会按预期工作:

// 期望:从 "getId" 反推出 "id"
type StripPrefix<T> = T extends `get${infer R}` ? Uncapitalize<R> : never;

type S = StripPrefix<"getId">;   // "id"  ← 这个可以,因为是显式匹配
// 但如果写在函数签名里期望自动推导参数,则不会发生

坑三:联合爆炸导致编译变慢。 组合数是乘积级的:

type A = "a" | "b" | "c" | "d" | "e";
type B = "1" | "2" | "3" | "4" | "5";
type C = "x" | "y" | "z";

type Explosion = `${A}-${B}-${C}`;   // 5 × 5 × 3 = 75 个成员

75 个还只是热身。真实项目里若有 20 个前缀 × 50 个键,瞬间就是 1000 个成员,编辑器补全和 tsc 都会肉眼可见地卡。判断与治理手段见 10.3 递归类型与类型性能治理 。

坑四:递归深度上限。 字符串越长,Split 这类递归的层数越多,超限时报:

Type instantiation is excessively deep and possibly infinite. ts(2589)

坑五:以为它能在运行时做校验。 模板字面量类型是纯编译期的,类型擦除后什么都不剩。运行时仍需要 schema 校验,可参考既有专题 /typescript-runtime-validation-typesafe/ 。

什么时候该用、什么时候不该用

场景建议
从固定前缀/后缀派生命名(onClick、getName)✅ 首选
解析结构稳定的字符串字面量(路由、版本号)✅ 适用
校验用户输入❌ 编译期无效,改用运行时校验
需要正则语义的转换(驼峰转 kebab)❌ 做不到
大联合的笛卡尔积⚠️ 先估算成员数,超过几百要拆分

延伸阅读:类型编程的更多进阶模式,可参考既有专题文章 /typescript-type-level-programming/ 与 /typescript-advanced-types/ 。

小结

  • 模板字面量类型用反引号加 ${} 表达「字符串模式」,占位符可放字面量、number、boolean 及其联合。
  • 占位符为联合类型时会自动展开为笛卡尔积,组合数是各位置成员数的乘积,是联合爆炸的根源。
  • 出现在条件类型 extends 右侧时充当模式,配 infer 可拆解字符串;匹配是非贪婪的,前一个 infer 尽量短。
  • 递归 + 模板字面量可以实现 Trim、Split、ReplaceAll 等类型级字符串算法,但没有正则能力。
  • 类型安全的事件名、路由参数、i18n 键是三个最有价值的落地场景。
  • 它是纯编译期机制,不能替代运行时校验;大联合上滥用会显著拖慢编译。

模板字面量类型把「字符串」这一维度带进了类型系统,代价则是递归与联合规模可能失控。下一节我们就来正面处理这个代价:递归类型怎么写才不会爆栈,以及如何测量并治理类型层面的性能问题。

阅读导航:上一节:10.1 内置工具类型全解 · 下一节:10.3 递归类型与类型性能治理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes