type Position = 'top' | 'right' | 'bottom' | 'left';但有时候,我们需要根据输入动态生成字符串类型,比如 'top-left'、'bottom-right'。直觉上你会写一堆联合类型的排列组合,但那样代码会变得臃肿且难以维护。
幸好,TypeScript 4.1 引入了一个神奇的特性——模板字面量类型。它就像 JavaScript 的模板字符串,只不过运行在类型世界里。
今天,我用自己的方式带你重新认识这个特性,并在实战中发现它的真正威力。
一、基础:类型也能用 ${}
模板字面量类型的语法和 ES6 模板字符串一模一样:
type Greeting = `Hello, ${string}`;// 等价于所有以 "Hello, " 开头的字符串let g: Greeting = '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'
二、内置工具:改变大小写的四个帮手
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}` ? { name: Name; ext: Ext } : 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'
TodoAction 作为 type 字段:type Action<T extends string> = { type: T; payload?: any };type TodoActionType = Action<TodoAction>;function createAddTodo(payload: string): TodoActionType {return { type: 'todos/addTodo', payload };}
这样,任何手误写了 'todos/addTodo1' 都会在编译时报错。从源头杜绝魔法字符串。
五、与映射类型结合:批量生成对象类型
有时候你需要为一个对象的每个键生成对应的修改器函数,比如 setXxx。可以用映射类型 + 模板字面量类型:
type State = {name: string;age: number;email: string;};type Setters = {[K in keyof State as `set${Capitalize<string & K>}`]: (value: State[K]) => void;};/*{setName: (value: string) => void;setAge: (value: number) => void;setEmail: (value: string) => void;}*/
这在封装 store 或 form 工具时非常有价值。
我自己写过一个轻量级表单库,利用这个模式自动生成了每个字段的 setValue、touched、error 等派生类型,减少了数百行样板代码。
六、性能、陷阱与我的思考
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 项目中尝试它。如果你有更酷的用法,欢迎留言和我交流!
夜雨聆风