乐于分享
好东西不私藏

TypeScript 模板字面量类型:让类型也能“字符串拼接”

TypeScript 模板字面量类型:让类型也能“字符串拼接”
大家好,我是阿浪,咱们聊聊TypeScript 
如果你是 TypeScript 的中度使用者,一定对字符串字面量联合类型不陌生:
type Position = 'top' | 'right' | 'bottom' | 'left';

但有时候,我们需要根据输入动态生成字符串类型,比如 'top-left''bottom-right'。直觉上你会写一堆联合类型的排列组合,但那样代码会变得臃肿且难以维护。

幸好,TypeScript 4.1 引入了一个神奇的特性——模板字面量类型。它就像 JavaScript 的模板字符串,只不过运行在类型世界里。

今天,我用自己的方式带你重新认识这个特性,并在实战中发现它的真正威力。

一、基础:类型也能用 ${}

模板字面量类型的语法和 ES6 模板字符串一模一样:

type Greeting = `Hello, ${string}`;// 等价于所有以 "Hello, " 开头的字符串let gGreeting = 'Hello, TypeScript'// ✅g = 'Hi there'// ❌ 必须以 Hello, 开头
更实用的是,与联合类型结合:
type Direction = 'top' | 'bottom';type Align = 'left' | 'right';type Position = `${Direction}-${Align}`; // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
类型系统自动生成了四个组合,省去了手写联合类型的痛苦!很多前端 CSS 类名生成、BEM 命名都可以用这种方式约束。

二、内置工具:改变大小写的四个帮手

TypeScript 内置了四个专用工具类型,用来在模板字面量类型中转换大小写:

  • Uppercase<StringType>

  • Lowercase<StringType>

  • Capitalize<StringType>(首字母大写)

  • Uncapitalize<StringType>(首字母小写)

例子:自动生成 CSS 变量的 getter / setter 风格

type CSSVarName = '--bg-color' | '--font-size';type CSSVarSetter = `set${Capitalize<CSSVarName>}`;// 'set--bg-color' | 'set--font-size'   😅 好像不太优雅// 更好的用法:去除前缀后处理type RawVar = 'bg-color' | 'font-size';type CamelVar = Uncapitalize<`${Capitalize<RawVar>}`>; // 先大写首字母再小写第1个字母// 不过这样有点绕,通常我们直接写映射
实际工作中,我更常这样用:
type EventName = 'click' | 'focus' | 'blur';type EventHandler = `on${Capitalize<EventName>}`; // 'onClick' | 'onFocus' | 'onBlur'

三、进阶:用 infer 玩模式匹配

模板字面量类型最强大的地方,是和 infer 搭配,从字符串中提取部分内容。

看一个解析文件名的例子:

type ParseFilename<T extends string> =   T extends `${infer Name}.${infer Ext}` ? { nameNameextExt } : never;type Res = ParseFilename<'document.pdf'>; // { name: 'document'; ext: 'pdf' }

这里面 infer Name 和 infer Ext 会分别捕获点号前后的部分。

如果文件名包含多级扩展名,例如 .tar.gz 呢?我们可以递归配合数组来搞定,但这篇文章不展开太深。

另一个我自己经常用的场景:解析路由参数

type ExtractRouteParams<T extends string> =   T extends `${infer Start}:${infer Param}/${infer Rest}`    ? { [K in Param | keyof ExtractRouteParams<Rest>]: string }    : T extends `${infer Start}:${infer Param}`    ? { [K in Param]: string }    : {};type Params = ExtractRouteParams<'/user/:id/post/:postId'>;// { id: string; postId: string }

这样就能在类型层面拿到路由的动态片段名称,为后续的 useParams 类型推导打下基础。

四、我的一个真实案例:消除重复的 action 类型

以前在写 Redux 或 Zustand 时,常常要定义一堆 action 名称常量:

const ADD_TODO = 'todos/addTodo';const REMOVE_TODO = 'todos/removeTodo';const UPDATE_TODO = 'todos/updateTodo';
这些字符串前缀 'todos/' 重复出现,不仅啰嗦,还容易打错。利用模板字面量类型 + 类型别名,可以自动生成这些字符串类型:
type SliceName = 'todos';type ActionName = 'addTodo' | 'removeTodo' | 'updateTodo';type TodoAction = `${SliceName}/${ActionName}`;// 'todos/addTodo' | 'todos/removeTodo' | 'todos/updateTodo'
然后在 action creator 的类型定义中直接使用 TodoAction 作为 type 字段:
type Action<T extends string> = { type: T; payload?: any };type TodoActionType = Action<TodoAction>;function createAddTodo(payloadstring): TodoActionType {  return { type'todos/addTodo', payload };}

这样,任何手误写了 'todos/addTodo1' 都会在编译时报错。从源头杜绝魔法字符串

五、与映射类型结合:批量生成对象类型

有时候你需要为一个对象的每个键生成对应的修改器函数,比如 setXxx。可以用映射类型 + 模板字面量类型:

type State = {  namestring;  agenumber;  emailstring;};type Setters = {  [K in keyof State as `set${Capitalize<string & K>}`]: (valueState[K]) => void;};/*{  setName: (value: string) => void;  setAge: (value: number) => void;  setEmail: (value: string) => void;}*/

这在封装 store 或 form 工具时非常有价值。

我自己写过一个轻量级表单库,利用这个模式自动生成了每个字段的 setValuetouchederror 等派生类型,减少了数百行样板代码。

六、性能、陷阱与我的思考

1. 笛卡尔积爆炸

模板字面量类型与多个联合类型组合时,会产生笛卡尔积。如果两个联合类型分别有 10 个成员,就会生成 100 种组合。如果三个或更多,组合数指数增长。TypeScript 编译器可能因此变慢甚至报错。

个人建议:尽量不要在模板字面量类型中同时使用三个以上的大联合类型。必要时可以拆分成多步,或者使用条件类型限制。

2. 大小写转换只能处理 ASCII

Capitalize 等工具只对 ASCII 字母有效,对中文、俄文、带重音的字符无效。例如 Capitalize<'école'> 会变成 'École'?实际上不会,它只识别 a-z,其他字符保持原样。所以如果你有国际化需求,慎用。

3. 调试建议

当你写了一个复杂的模板字面量类型,发现没有按照预期工作,可以用一个简单的工具类型来“打印”中间结果:

type Debug<T> = T extends any ? { [K in keyof T]: T[K] } : never;

把 Debug<YourComplicatedType> hover 一下,就能看清类型的具体形状。

七、给我启发最大的两个应用场景

最后,分享两个我认为最有创意、且个人项目中真正受益的场景。

1. 类型安全的国际化键值路径

假设你有一个嵌套的翻译对象:

const messages = {  common: { ok'确认'cancel'取消' },  user: { profile: { title'个人资料'edit'编辑' } }};
可以通过模板字面量类型和递归,推导出所有可能的路径字符串 'common.ok''user.profile.title' 等,然后写一个类型安全的 t 函数:
function t<K extends AllPaths<typeof messages>>(key: K): string;

这样你永远不可能写出 'user.profile.tile' 这样的错误 key,编译器会直接提示。

2. 把 API 端点定义为类型

配合路径参数和查询字符串:

type APIEndpoints =   | '/users'  | `/users/${number}`  | `/users/${number}/posts`  | `/posts?userId=${number}`;

然后 fetch 函数的参数类型就可以被严格限制,减少 URL 拼接错误。

八、总结与未来

模板字面量类型是我认为 TypeScript 近几年最被低估的特性之一。它让类型系统真正拥有了“字符串计算”的能力,将很多原本在运行时才能发现的错误提前到编译阶段。

当然,不要滥用。如果一个普通的联合类型就能解决问题,就没必要引入复杂的类型体操。但当你在编写通用库、框架或复杂的业务模型时,模板字面量类型会像个老朋友一样帮你写出更干净、更安全的代码。

希望这篇文章能让你对它有新的理解,并在你的下一次 TS 项目中尝试它。如果你有更酷的用法,欢迎留言和我交流!

如果觉得阿浪写得还行,麻烦动动小手点个关注,点个赞,我会持续更新更多硬核干货,谢谢大家!