模板字面量类型

模板字面量类型

写在前面

上一篇讲了泛型与条件类型。这一篇讲模板字面量类型(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 的工作机制。

参考资料