作为一个从 TypeScript 2.x 时代就开始折腾类型的老玩家,我见过太多人把类型系统当成一个"报错工具"来用,写完 interface 就完事。但真正把类型玩明白的人都在干一件更有趣的事:用类型系统去约束领域逻辑、消灭一整类运行时错误。而模板字面量类型(Template Literal Types)和它背后的类型操作组合拳,是我认为近几年 TypeScript 类型系统最有想象力的能力之一。
先说清楚一件事:模板字面量类型不是模板字符串。模板字符串是运行时语法,把变量拼进字符串;模板字面量类型是编译期类型系统里的语法,把字面量类型拼进字符串类型。很多人把两个概念混在一起,然后在 4.1 版本发布之后看到 Uppercase、Capitalize 这些工具类型一脸懵,其实原理一句话就能说清:类型系统里也能做字符串运算了。
这篇文章我不会按官方文档的顺序来,而是按我自己在实际项目里摸索出的"由浅入深"路线来写。先讲清楚基础用法能解决什么问题,再讲 infer 怎么从字符串里抠出动态参数,然后是映射类型、条件类型、模板字面量类型如何组合成一套完整的安全体系,最后用一个真实场景的 HTTP 客户端案例把所有东西串起来,顺便聊聊那些坑:联合类型爆炸、递归深度限制、类型推断边界。适合已经能熟练使用泛型、条件类型和映射类型,但想更进一步把类型操作真正落地到项目里的读者。
1. 模板字面量类型的底色:为什么普通 string 不够用
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1.1 从"看起来是 string,其实是关键业务规则"说起
在模板字面量类型出现之前,字符串类型几乎等于 string,顶多用字符串字面量联合类型表达几个固定值。比如:
typescript复制type ButtonSize = 'small' | 'medium' | 'large';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
这种写法能表达"有限集合",但表达不了"两个集合之间的组合关系"。一个按钮的状态,可能是 small-primary、large-danger,也可能是 medium-secondary。如果我只是分别定义两个联合类型,那 size-variant 这个拼接出来的字符串就完全失控了——它可以是任意组合,包括 small-danger、medium-primary 这种业务上禁止出现的状态。
模板字面量类型解决的就是这个问题:让字符串的类型表达能力和运行时拼接能力对齐。
typescript复制type ButtonSize = 'small' | 'medium' | 'large';
type ButtonVariant = 'primary' | 'secondary' | 'danger';
// 生成所有合法组合的字符串类型
type ButtonClass = `${ButtonSize}-${ButtonVariant}`;
// "small-primary" | "small-secondary" | "small-danger"
// | "medium-primary" | "medium-secondary" | "medium-danger"
// | "large-primary" | "large-secondary" | "large-danger"
这种写法不是在"重复声明"组合,而是在"推导"组合。以后如果加了 'huge' 尺寸,或者 'warning' 变体,ButtonClass 会自动扩到对应范围,不需要人肉同步维护。我接触过太多因为漏改字符串拼接导致线上样式错乱的事故,这类问题在类型层面就能直接拦截。
1.2 模板字符串与模板字面量类型的分界线
很多新手会在运行时尝试调用一个"类型方法",或者反过来在类型定义里写运行时逻辑,两种思路都拧巴了。
typescript复制// 运行时模板字符串
const greet = (name: string) => `Hello, ${name}`;
// 类型层面的模板字面量类型
type Greeting<N extends string> = `Hello, ${N}`;
type Name = 'TypeScript';
type Result = Greeting<Name>; // "Hello, TypeScript"
关键在于:Greeting<Name> 里的 <N extends string> 泛型约束可以接住任何字符串字面量类型,拼接结果也是一个字符串字面量类型。但你不能把 Result 直接拿来当运行时值用,类型在编译期就被擦除了。这是大部分初学模板字面量类型的人第一个认知障碍——它和运行时模板字符串语法长得太像,但活在完全不同的层面。
在实际项目中,我通常把模板字面量类型当成"类型层面的规则引擎"来用。运行时拿到什么字符串,是 API 返回的、用户输入的还是组件拼出来的,都无所谓;重要的是从前端代码那些静态可推导的字符串出发,让类型系统提前卡住所有不合法的组合。
2. 四个内置字符串工具类型:Uppercase、Lowercase、Capitalize、Uncapitalize 的应用场景
很多人对这四个工具类型的第一反应是"这有什么用?字符串转大写运行时一个 .toUpperCase() 就搞定了"。这话没错,但类型层面的转换有两个运行时做不到的点:一是类型本身会跟着变,二是它可以反向约束。
2.1 从后端枚举生成前端状态映射
我在一个后台管理项目里遇到过这种需求:后端返回的枚举是 SUCCESS、FAILED、PENDING,但前端组件库需要的状态是 success、failed、pending。以前的写法是维护一份映射对象,再写一堆断言:
typescript复制const statusMap = {
SUCCESS: 'success',
FAILED: 'failed',
PENDING: 'pending',
} as const;
type BackendStatus = keyof typeof statusMap;
type FrontendStatus = (typeof statusMap)[BackendStatus];
现在有了模板字面量类型,我可以定义一个通用的"小写化"工具,直接从后端枚举推导出前端枚举,映射关系天然一致:
typescript复制type LowercaseKeys<T extends string> = Lowercase<T>;
type BackendStatus = 'SUCCESS' | 'FAILED' | 'PENDING';
type FrontendStatus = LowercaseKeys<BackendStatus>;
// 'success' | 'failed' | 'pending'
如果哪天后端加了一个 PARTIAL_SUCCESS,BackendStatus 更新后,FrontendStatus 会自动多出 partial_success,不需要再手写一遍。这里有一个实际生产里值得注意的坑:Lowercase<T> 等四个工具类型只对字面量类型生效,对 string 这种宽泛类型会直接返回 string,推导就断了。所以这类工具一定要配合具体字面量或者 ${infer _} 这种条件分支来用。
2.2 Capitalize 与组件命名规范
还有一个常用场景是事件名和回调函数名的映射。比如我在项目里封装过一个小型事件总线,事件名是 userCreated、postDeleted 这种驼峰格式,但后端 webhook 的消息名是 user.created、post.deleted。用 Capitalize 能把点号分隔的字符串转换成驼峰事件名:
typescript复制type EventName<T extends string> =
T extends `${infer Prefix}.${infer Rest}`
? `${Capitalize<Prefix>}${EventName<Rest>}`
: Capitalize<T>;
type WebhookEvent = 'user.created' | 'post.deleted' | 'system.
