模板字面量类型

2026-09-09 00:00    #typescript  

模板字面量类型(TS 4.1+)把字符串拼接和模式匹配搬到了类型层面。它是"批量改写键名"和"解析字符串结构"的核心工具。

基础

  1// 模板字面量类型的模式匹配
  2
  3// ---- 基础拼接 ----
  4type EventName = "click" | "focus";
  5type Handler = `on${Capitalize<EventName>}`;   // "onClick" | "onFocus"
  6
  7// 联合会做笛卡尔积
  8type Size = "sm" | "md";
  9type Color = "red" | "blue";
 10type Variant = `${Size}-${Color}`;             // 4 个成员
 11
 12// ---- 四个内置字符串操作类型 ----
 13type A = Uppercase<"abc">;      // "ABC"
 14type B = Lowercase<"ABC">;      // "abc"
 15type C = Capitalize<"abc">;     // "Abc"
 16type D = Uncapitalize<"Abc">;   // "abc"
 17
 18// ---- infer 提取 ----
 19// 提取路径剩余部分
 20type StripLeadingSlash<T> = T extends `/${infer Rest}` ? Rest : never;
 21type E = StripLeadingSlash<"/users/1">;        // "users/1"
 22
 23// 只切第一段
 24type SplitFirst<T> = T extends `${infer Head}/${infer Tail}` ? [Head, Tail] : never;
 25type F = SplitFirst<"a/b/c">;                  // ["a", "b/c"]
 26
 27// 解析键值对
 28type ParseKV<T> = T extends `${infer K}=${infer V}` ? { key: K; value: V } : never;
 29type G = ParseKV<"name=rainboy">;              // { key: "name"; value: "rainboy" }
 30
 31// 提取路径参数
 32type PathParams<T> = T extends `${string}:${infer P}/${infer Rest}`
 33  ? P | PathParams<`/${Rest}`>
 34  : T extends `${string}:${infer P}`
 35    ? P
 36    : never;
 37type H = PathParams<"/users/:id/posts/:postId">;   // "id" | "postId"
 38
 39// ---- 递归转换 ----
 40// 蛇形转驼峰
 41type SnakeToCamel<S extends string> =
 42  S extends `${infer Head}_${infer Tail}`
 43    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
 44    : S;
 45type I = SnakeToCamel<"user_first_name">;      // "userFirstName"
 46
 47// 驼峰转蛇形
 48type CamelToSnake<S extends string> =
 49  S extends `${infer Head}${infer Tail}`
 50    ? Head extends Uppercase<Head>
 51      ? Head extends Lowercase<Head>
 52        ? `${Head}${CamelToSnake<Tail>}`
 53        : `_${Lowercase<Head>}${CamelToSnake<Tail>}`
 54      : `${Head}${CamelToSnake<Tail>}`
 55    : S;
 56type J = CamelToSnake<"userFirstName">;        // "user_first_name"
 57
 58// 替换字符串
 59type Replace<S extends string, From extends string, To extends string> =
 60  S extends `${infer Head}${From}${infer Tail}` ? `${Head}${To}${Tail}` : S;
 61type K = Replace<"a-b-c", "-", "_">;           // "a_b-c"(只替换第一处)
 62
 63// 全部替换(递归)
 64type ReplaceAll<S extends string, From extends string, To extends string> =
 65  S extends `${infer Head}${From}${infer Tail}`
 66    ? `${Head}${To}${ReplaceAll<Tail, From, To>}`
 67    : S;
 68type L = ReplaceAll<"a-b-c", "-", "_">;        // "a_b_c"
 69
 70// ---- 与映射类型组合:批量改键名 ----
 71interface ApiResponse {
 72  user_id: number;
 73  user_name: string;
 74  created_at: string;
 75}
 76type CamelKeys<T> = {
 77  [P in keyof T as SnakeToCamel<string & P>]: T[P];
 78};
 79type M = CamelKeys<ApiResponse>;
 80// { userId: number; userName: string; createdAt: string }
 81
 82// ---- 数字与 bigint ----
 83type SemverMajor = `v${number}`;
 84const v1: SemverMajor = "v1";
 85const v1_5: SemverMajor = "v1.5";
 86// @ts-expect-error TS2322: "v1.2.3" 不是单个 number 字面量
 87const v1_2_3: SemverMajor = "v1.2.3";
 88
 89// 需要任意字符串时用 string
 90type Version = `v${string}`;
 91const vAny: Version = "v1.2.3";
 92
 93// 布尔也会被转成字符串
 94type BoolStr = `${boolean}`;                   // "true" | "false"
 95const bt: BoolStr = "true";
 96
 97// null / undefined
 98type NullStr = `${null}`;                      // "null"
 99const nt: NullStr = "null";
100
101// ---- 过滤:判断是否以某前缀开头 ----
102type StartsWith<T, P extends string> = T extends `${P}${string}` ? true : false;
103type N = StartsWith<"onClick", "on">;          // true
104type O = StartsWith<"click", "on">;            // false
105
106const a: A = "ABC";
107const b: B = "abc";
108const c: C = "Abc";
109const d: D = "abc";
110const e: E = "users/1";
111const f: F = ["a", "b/c"];
112const g: G = { key: "name", value: "rainboy" };
113const h: H = "id";
114const i: I = "userFirstName";
115const j: J = "user_first_name";
116const k: K = "a_b-c";
117const l: L = "a_b_c";
118const m: M = { userId: 1, userName: "a", createdAt: "d" };
119const variant: Variant = "sm-red";
120const n: N = true;
121const o: O = false;
122
123console.log(a, b, c, d, e, f, g, h, i, j, k, l, m, variant, v1, v1_5, vAny, bt, nt, n, o, v1_2_3);
1ABC abc Abc abc users/1 [ 'a', 'b/c' ] { key: 'name', value: 'rainboy' } id userFirstName user_first_name a_b-c a_b_c { userId: 1, userName: 'a', createdAt: 'd' } sm-red v1 v1.5 v1.2.3 true null true false v1.2.3

拼接语法和运行时模板字符串一样,只是位置在类型里:

1type EventName = "click" | "focus";
2type Handler = `on${Capitalize<EventName>}`;   // "onClick" | "onFocus"

联合类型会做笛卡尔积:

1type Size = "sm" | "md";
2type Color = "red" | "blue";
3type Variant = `${Size}-${Color}`;   // 4 个成员的联合

四个内置字符串操作类型

1Uppercase<"abc">      // "ABC"
2Lowercase<"ABC">      // "abc"
3Capitalize<"abc">     // "Abc"
4Uncapitalize<"Abc">   // "abc"

它们只作用于字面量字符串类型。传入 string 会得到 string。

用 infer 做模式匹配

 1// 去掉前导斜杠
 2type StripLeadingSlash<T> = T extends `/${infer Rest}` ? Rest : never;
 3type E = StripLeadingSlash<"/users/1">;        // "users/1"
 4
 5// 只切第一段
 6type SplitFirst<T> = T extends `${infer Head}/${infer Tail}` ? [Head, Tail] : never;
 7type F = SplitFirst<"a/b/c">;                  // ["a", "b/c"]
 8
 9// 解析键值对
10type ParseKV<T> = T extends `${infer K}=${infer V}` ? { key: K; value: V } : never;
11type G = ParseKV<"name=rainboy">;              // { key: "name"; value: "rainboy" }

注意 SplitFirst<"a/b/c"> 的结果是 ["a", "b/c"]——非贪婪匹配,只切第一段。

递归转换

模板字面量类型可以递归,这是"字符串算法"在类型层面的实现:

1// 蛇形转驼峰
2type SnakeToCamel<S extends string> =
3  S extends `${infer Head}_${infer Tail}`
4    ? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
5    : S;
6
7type I = SnakeToCamel<"user_first_name">;      // "userFirstName"

全部替换需要递归(因为 infer 只匹配第一处):

 1type Replace<S extends string, From extends string, To extends string> =
 2  S extends `${infer Head}${From}${infer Tail}` ? `${Head}${To}${Tail}` : S;
 3
 4type K = Replace<"a-b-c", "-", "_">;           // "a_b-c"(只替换第一处)
 5
 6type ReplaceAll<S extends string, From extends string, To extends string> =
 7  S extends `${infer Head}${From}${infer Tail}`
 8    ? `${Head}${To}${ReplaceAll<Tail, From, To>}`
 9    : S;
10
11type L = ReplaceAll<"a-b-c", "-", "_">;        // "a_b_c"

与映射类型组合:批量改键名

这是模板字面量类型最有价值的应用:

 1interface ApiResponse {
 2  user_id: number;
 3  user_name: string;
 4  created_at: string;
 5}
 6
 7type CamelKeys<T> = {
 8  [P in keyof T as SnakeToCamel<string & P>]: T[P];
 9};
10
11type M = CamelKeys<ApiResponse>;
12// { userId: number; userName: string; createdAt: string }

string & P 是必需的——keyof T 的元素类型是 string | number | symbol,而模板字面量只接受字符串。详见 映射类型。

判断前缀/后缀

1type StartsWith<T, P extends string> = T extends `${P}${string}` ? true : false;
2
3type N = StartsWith<"onClick", "on">;   // true
4type O = StartsWith<"click", "on">;     // false

${number} 的边界

1type SemverMajor = `v${number}`;
2
3const v1: SemverMajor = "v1";        // ✓
4const v1_5: SemverMajor = "v1.5";    // ✓
5
6const v1_2_3: SemverMajor = "v1.2.3";
7// error TS2322: Type '"v1.2.3"' is not assignable to type '`v${number}`'.

${number} 只匹配单个合法的数字字面量。1.2.3 不是合法数字,所以不匹配。需要任意字符串时用 ${string}:

1type Version = `v${string}`;
2const vAny: Version = "v1.2.3";      // ✓

其他占位类型

占位匹配实测通过的示例
${string}任意字符串"anything"
${number}合法数字字面量"1"、"1.5"、"-1"、"1e3"、"0x10"
${bigint}整数字面量"1"、"1n"
${boolean}布尔"true" | "false"
${null}null"null"
${undefined}undefined"undefined"

实测确认 ${number} 的匹配范围比想象的宽——负数、科学计数法、十六进制都算合法数字字面量,都会通过。它只排除真正不合法的形式(如 "1.2.3"、"vabc"):

1error TS2322: Type '"v1.2.3"' is not assignable to type '`v${number}`'.
2error TS2322: Type '"vabc"' is not assignable to type '`v${number}`'.

实用模式

需求写法
加前缀`get${Capitalize<K>}`
去前缀K extends `${P}${infer R}` ? R : K
全部替换递归
判断前缀T extends `${P}${string}`
解析键值T extends `${infer K}=${infer V}`

相关