本节目标:读完这一节,你能用反引号加
${}写出模板字面量类型并预测它的展开结果;能说清它与联合类型组合时的笛卡尔积规则;能用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"
这段代码有四个值得注意的细节:
- 第一个分支匹配「
:参数后面还有/」的情况,说明参数名后面还有更多路径,于是递归处理剩下的部分。 - 递归时把
Rest重新拼上前导/(`/${Rest}`),是为了让下一轮的`${string}:${infer Param}`模式能正确对齐。 - 第二个分支是终止条件:只剩一个
:参数且后面没有/了。 - 两个分支的结果用
|合并,于是所有参数名汇聚成一个联合类型。
有了参数名,就能进一步推导出「路径 → 参数对象」的映射:
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 递归类型与类型性能治理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。