模板字面量类型
模板字面量类型
写在前面
上一篇讲了泛型与条件类型。这一篇讲模板字面量类型(Template Literal Types)——TypeScript 4.1+ 引入的"字符串类型魔法"。
模板字面量让 TS 可以在类型层面操作字符串——拼接、提取、转换。这把"动态字符串"的能力带到了类型系统。
一、模板字面量基础
1.1 基础语法
type Greeting = `hello, ${string}`;
let g1: Greeting = "hello, world"; // OK
let g2: Greeting = "hello, TypeScript"; // OK
let g3: Greeting = "hi, world"; // 报错 - 不符合 hello, xxx
跟 JS 的模板字符串类似,但作用在类型系统。
1.2 联合类型展开
type Lang = "en" | "zh" | "ja";
type Greeting = `hello_${Lang}`;
// "hello_en" | "hello_zh" | "hello_ja"
每个联合成员都参与拼接——生成新的联合类型。
1.3 实战:事件命名约定
type Entity = "user" | "post" | "comment";
type Action = "create" | "update" | "delete";
type Event = `${Entity}:${Action}`;
// "user:create" | "user:update" | "user:delete"
// | "post:create" | "post:update" | "post:delete"
// | "comment:create" | "comment:update" | "comment:delete"
1.4 实战:HTTP 方法路径
type ApiVersion = "v1" | "v2";
type Resource = "users" | "orders" | "products";
type ApiPath = `/api/${ApiVersion}/${Resource}`;
// "/api/v1/users" | "/api/v1/orders" | "/api/v1/products"
// | "/api/v2/users" | "/api/v2/orders" | "/api/v2/products"
二、字符串工具类型
2.1 内置工具
TypeScript 内置了四个编译时字符串转换工具(需要 TS 4.1+):
type A = Uppercase<"hello">; // "HELLO"
type B = Lowercase<"HELLO">; // "hello"
type C = Capitalize<"hello">; // "Hello"
type D = Uncapitalize<"Hello">; // "hello"
这些是编译时操作——TypeScript 直接算出结果,不依赖运行时。
2.2 实战:CSS 属性
type Direction = "top" | "bottom" | "left" | "right";
type Margin = `margin-${Direction}`; // "margin-top" | ...
type Padding = `padding-${Direction}`; // "padding-top" | ...
// 数值尺寸
type Size = "0" | "1" | "2" | "4" | "8";
type Spacing = `${Size}px` | `${Size}rem`;
2.3 实战:HTML 属性
type DataAttrName = `data-${string}`;
type AriaAttrName = `aria-${string}`;
type HtmlAttr = string | DataAttrName | AriaAttrName;
三、infer 提取字符串部分
3.1 基础提取
模板字面量类型最强大的地方是配合 infer 提取。
// 提取文件扩展名
type GetExtension<T> = T extends `${string}.${infer Ext}` ? Ext : never;
type A = GetExtension<"photo.png">; // "png"
type B = GetExtension<"video.mp4">; // "mp4"
type C = GetExtension<"README">; // never
3.2 实战:解析 URL
type ParseUrl<T> =
T extends `${infer Protocol}://${infer Rest}`
? { protocol: Protocol; rest: Rest }
: never;
type Result = ParseUrl<"https://example.com/users">;
// { protocol: "https"; rest: "example.com/users" }
3.3 实战:解析 query string
type ParseQuery<T> =
T extends `${infer Key}=${infer Value}&${infer Rest}`
? { [K in Key | keyof ParseQuery<Rest>]: K extends Key ? Value : ParseQuery<Rest>[K] }
: T extends `${infer Key}=${infer Value}`
? { [K in Key]: Value }
: {};
type Q = ParseQuery<"foo=bar&baz=qux">;
// { foo: "bar"; baz: "qux" }
这个比较复杂——递归地把 query string 转成对象类型。
四、实战:路由路径类型化
4.1 路由定义
type Route =
| "/"
| "/users"
| "/users/:id"
| "/users/:id/posts"
| "/users/:id/posts/:postId"
| "/orders"
| "/orders/:orderId";
4.2 提取路径参数
// 提取路径里所有的 :param
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:id/posts/:postId">;
// "id" | "postId"
4.3 生成参数对象类型
type ParamsObject<T extends string> = {
[K in ExtractParams<T>]: string;
};
type UserPostParams = ParamsObject<"/users/:id/posts/:postId">;
// { id: string; postId: string }
4.4 类型安全的路由函数
function navigate<T extends Route>(
path: T,
params: ParamsObject<T>
): void {
let url = path;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, value) as T;
}
// 实际导航逻辑
history.pushState(null, "", url);
}
// 用法
navigate("/users/:id", { id: "123" }); // OK
navigate("/users/:id/posts/:postId", { id: "123", postId: "456" }); // OK
navigate("/users/:id", { userId: "123" }); // 报错 - 应该是 id
navigate("/users/:id/posts/:postId", { id: "123" }); // 报错 - 缺 postId
强类型路由——拼写错误、缺参数、多参数全部编译报错。
五、实战:CSS-in-JS 类型化
5.1 数值单位
type CssUnit = "px" | "rem" | "em" | "%" | "vh" | "vw" | "fr";
type CssValue = `${number}${CssUnit}` | "0" | "auto";
interface BoxStyle {
width?: CssValue;
height?: CssValue;
margin?: CssValue;
padding?: CssValue;
}
// 用法
const style: BoxStyle = {
width: "100px",
height: "50%",
margin: "0",
padding: "1rem",
};
const bad: BoxStyle = {
width: "100", // 报错 - 需要单位
margin: "10px solid red", // 报错 - 太复杂,不符合 CssValue
};
5.2 响应式断点
type Breakpoint = "sm" | "md" | "lg" | "xl";
type MediaQuery = `@media (min-width: ${number}px)`;
const mobileFirst = "@media (min-width: 768px)" as const;
// 可以匹配 MediaQuery 类型
六、实战:i18n 键类型化
6.1 嵌套 key 提取
// i18n 资源结构
type Resources = {
common: {
hello: "你好";
bye: "再见";
};
user: {
profile: {
title: "个人资料";
edit: "编辑资料";
};
};
};
// 提取所有 key 路径(如 "common.hello" "user.profile.title")
type DotPath<T, Prev extends string = ""> = {
[K in keyof T]: K extends string
? T[K] extends string
? `${Prev}${K}`
: DotPath<T[K], `${Prev}${K}.`>
: never;
}[keyof T];
type Keys = DotPath<Resources>;
// "common.hello" | "common.bye"
// | "user.profile" | "user.profile.title" | "user.profile.edit"
自动从嵌套对象生成所有 key 路径——加新翻译时类型自动更新。
6.2 类型安全的 t 函数
function t(key: DotPath<Resources>): string {
// 实际查资源
return key;
}
// 用法
t("common.hello"); // OK
t("user.profile.title"); // OK
t("user.profile"); // OK
t("foo.bar"); // 报错 - foo.bar 不是合法 key
七、字符串操作的几个套路
7.1 套路:字符串转联合
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"
7.2 套路:联合转字符串(用 getter)
type Join<T extends string[], D extends string> =
T extends [] ? "" :
T extends [infer F] ? F :
T extends [infer F extends string, ...infer R extends string[]]
? `${F}${D}${Join<R, D>}`
: "";
type Joined = Join<["a", "b", "c"], "-">; // "a-b-c"
7.3 套路:首字母大写
type MyCapitalize<S extends string> = S extends `${infer F}${infer R}`
? `${Uppercase<F>}${R}`
: S;
type A = MyCapitalize<"hello">; // "Hello"
7.4 套路:字符串替换
type Replace<S extends string, From extends string, To extends string> =
S extends `${infer Head}${From}${infer Tail}`
? `${Head}${To}${Tail}`
: S;
type A = Replace<"hello world", "world", "TypeScript">; // "hello TypeScript"
7.5 套路:移除前缀
type StripPrefix<S extends string, P extends string> =
S extends `${P}${infer Rest}` ? Rest : S;
type A = StripPrefix<"data-foo", "data-">; // "foo"
八、字符串类型体操的边界
8.1 不能做循环
类型系统没有循环——只能用递归。
// 这是合法的递归
type Reverse<S extends string> =
S extends `${infer F}${infer Rest}` ? `${Reverse<Rest>}${F}` : S;
type A = Reverse<"hello">; // "olleh"
// 但嵌套太深会报错
// Type instantiation is excessively deep and possibly infinite
8.2 不能做正则
模板字面量不是正则——只能做固定的字符匹配。
// 可以
type IsHex<T> = T extends `[0-9a-fA-F]` ? T : never;
// 不能 - 不能用正则量词
type IsValid<T> = T extends `${string}${number}+` ? T : never;
8.3 类型复杂度爆炸
复杂的字符串操作会让编译变慢——TypeScript 编译器要展开所有可能性。
// NG - 联合太多会导致编译慢
type TooMany = `${big联合1}_${big联合2}`;
联合类型大小不要超过 10000 个成员——超过会显著变慢。
九、实战:类型安全的 localStorage
9.1 定义 schema
interface AppStorage {
user: User;
token: string;
theme: "light" | "dark";
language: "zh" | "en";
}
9.2 类型安全包装
function getItem<K extends keyof AppStorage>(key: K): AppStorage[K] | null {
const raw = localStorage.getItem(key);
if (raw === null) return null;
return JSON.parse(raw) as AppStorage[K];
}
function setItem<K extends keyof AppStorage>(key: K, value: AppStorage[K]): void {
localStorage.setItem(key, JSON.stringify(value));
}
// 用法
const user = getItem("user"); // User | null
const theme = getItem("theme"); // "light" | "dark" | null
setItem("token", "xxx");
setItem("theme", "blue"); // 报错 - "blue" 不是合法 theme
十、实战:CSS class 拼接
10.1 classnames helper
type ClassValue = string | number | boolean | undefined | null;
function cls(...values: ClassValue[]): string {
return values.filter(Boolean).join(" ");
}
// 用法
const className = cls("btn", isPrimary && "btn-primary", `size-${size}`);
// "btn btn-primary size-md"
10.2 tailwind class 类型化(高级)
// tailwind 的颜色
type TailwindColor =
| "red" | "blue" | "green" | "yellow" | "purple"
| "gray" | "zinc" | "neutral" | "stone";
type Shade = "50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
type BgColor = `bg-${TailwindColor}-${Shade}`;
type TextColor = `text-${TailwindColor}-${Shade}`;
const btn: { class: BgColor } = { class: "bg-blue-500" }; // OK
const bad: { class: BgColor } = { class: "bg-bule-500" }; // 报错 - 拼写错误
typo 编译就能发现——比 ESLint 还早一步。
十一、本篇小结
| 概念 | 关键点 |
|---|---|
| 模板字面量 | 类型层面的字符串拼接 |
| 联合展开 | 联合成员参与拼接 |
| 内置工具 | Uppercase / Lowercase / Capitalize |
| infer 提取 | 从字符串中提取部分 |
| 路由类型化 | 路径参数自动推断 |
| i18n 类型化 | 自动从嵌套对象生成 key |
| 实战 | 应用 |
|---|---|
| 路由 | 强类型路径参数 |
| CSS-in-JS | 数值单位类型化 |
| i18n | 自动 key 路径 |
| localStorage | 强类型 storage |
| API | 强类型 URL 模板 |
下一篇讲 声明文件与 .d.ts——给没有类型的 JS 库补类型,以及 DefinitelyTyped 的工作机制。