前端知库

模板字面量类型和映射类型的 as 重映射怎么用?

模板字面量类型用反引号在类型层面拼字符串,插值位置放联合类型时会把所有组合都展开。配合 Uppercase、Capitalize 等内置字符串类型和 infer,可以拼出或拆开字符串字面量;在映射类型里用 as 改键名,就能从一个接口生成 getName 这样的 getter 名或 onNameChange 这样的事件名。

参考答案

简答

比如接口地址只允许 GET /users、POST /orders 这类格式,写错大小写或者路径就报错。用模板字面量类型可以写成 `${Uppercase<Method>} ${Route}`,它和 JS 的模板字符串长得一样,只不过拼出来的是字符串字面量类型。

插值的地方放联合类型时,TypeScript 会把每个成员都代进去,多个插值位置之间按所有组合展开。Uppercase、Lowercase、Capitalize、Uncapitalize 是编译器内置的字符串类型,用来改大小写。反过来,在条件类型里用 infer 配合模板,可以把字符串拆开,比如从 '/users/:id' 里取出参数名 id。

最常见的用法是和映射类型的 as 一起,从一个接口生成新键名。[K in keyof T as `get${Capitalize<string & K>}`] 能把 name 变成 getName;同样的写法可以生成 onNameChange 这样的事件名,再让回调参数的类型跟着对应属性走。as 的结果是 never 时,这个键就被去掉。

要注意两点。第一,组合展开的数量是相乘的,联合类型太大时编译会变慢,Handbook 建议大的字符串联合提前生成。第二,keyof T 可能包含 number 和 symbol 键,Capitalize 只接受字符串,所以要写成 string & K 把非字符串键过滤掉。

模板字面量类型怎样拼字符串

先看一个限制接口格式的例子。Endpoint 由请求方法和路径拼成,方法要求大写:

TypeScript
type Method = 'get' | 'post';
type Route = '/users' | '/orders';
type Endpoint = `${Uppercase<Method>} ${Route}`;
// 'GET /users' | 'GET /orders' | 'POST /users' | 'POST /orders'

const ok: Endpoint = 'GET /users';
// @ts-expect-error 方法必须大写
const bad: Endpoint = 'get /users';

Endpoint 一共有四个成员。为什么是四个?按 Handbook 的说法,插值位置是联合类型时,结果是每个成员代进去得到的所有字符串;有多个插值位置时,各个位置的联合会交叉相乘。这里方法有 2 个、路径有 2 个,所以是 2 × 2 = 4 个。

Uppercase<Method> 先把 'get' | 'post' 变成 'GET' | 'POST',再参与拼接。这几个内置类型由编译器直接实现,在 .d.ts 文件里找不到定义。Handbook 还说明,它们用的是 JavaScript 的 toUpperCase 这类方法,不考虑语言区域。

正因为是相乘,联合类型一大,展开后的数量会迅速增长。比如三个各有 20 个成员的联合拼在一起,就是 8000 个成员。Handbook 建议,大的字符串联合最好提前生成好,模板字面量类型更适合小规模的组合。

怎样用 infer 把字符串拆开

模板字面量类型也能反过来用。在条件类型里,把 infer 放进模板的插值位置,TypeScript 会去匹配字符串,把对应的部分推断出来。条件类型和 infer 本身的读法见《条件类型、infer 和分布式条件类型如何理解?》,这里看两个拆字符串的例子。

第一个是从路由里取出参数名,让参数对象的键必须和路由一致:

TypeScript
type RouteParams<S extends string> =
  S extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : S extends `${string}:${infer Param}`
      ? Param
      : never;

type Params = RouteParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'

const params: Record<Params, string> = { userId: '1', postId: '2' };
// @ts-expect-error 少了 postId
const missing: Record<Params, string> = { userId: '1' };

第一个分支匹配“冒号后面跟着参数名、再跟一个斜杠”的情况,取出 userId,剩下的 posts/:postId 补上斜杠再递归处理。到最后一段没有斜杠了,就走第二个分支,取出 postId。所以结果是两个参数名的联合,少写一个就会报错。

第二个是把下划线命名转成驼峰:

TypeScript
type CamelCase<S extends string> =
  S extends `${infer Head}_${infer Rest}`
    ? `${Head}${CamelCase<Capitalize<Rest>>}`
    : S;

type Field = CamelCase<'created_at_time'>; // 'createdAtTime'

每次在第一个下划线处切开,后半段首字母大写再继续递归,直到没有下划线为止。这类递归类型在类型层面很好用,但每多一层递归,编译器就多做一次计算,处理很长的字符串时要留意编译速度。

映射类型里的 as 怎样改键名

从 TypeScript 4.1 开始,映射类型可以在 in 后面写 as,as 后面就是新的键名。配合模板字面量类型,可以从一个接口生成另一套名字:

TypeScript
interface User {
  name: string;
  age: number;
}

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

为什么要写 string & K,不直接写 Capitalize<K>?因为 keyof T 得到的键可能是 string、number 或 symbol,而 Capitalize 只接受字符串。和 string 取交叉以后,数字键和 symbol 键变成 never,拼出来的键名也是 never,这些键就被去掉了。

值的类型写的是 () => T[K],K 在这里仍然是原来的键,所以 getName 返回 string,getAge 返回 number,改名不会把类型对应关系弄丢。

as 的结果是 never 时键会被删掉,所以它也能用来过滤属性。比如只保留值为函数的属性:

TypeScript
type OnlyMethods<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

type Methods = OnlyMethods<{ count: number; reset(): void }>;
// { reset: () => void }

映射类型的基本写法和 Partial、Pick 这些内置工具见《映射类型与 Partial、Pick、Omit 如何用于接口建模?》。

怎样生成类型安全的事件名

另一个常见场景是事件。假设一个对象的每个属性变化时都会发事件,事件名是属性名加 Changed。Handbook 给出的写法是让 on 方法带一个泛型,从事件名里推断出属性名:

TypeScript
type Watched<T> = {
  on<K extends string & keyof T>(
    eventName: `${K}Changed`,
    callback: (newValue: T[K]) => void,
  ): void;
};

declare const user: Watched<User>;

user.on('ageChanged', (value) => value.toFixed()); // value 是 number
// @ts-expect-error 属性名拼错了
user.on('agChanged', () => {});

调用 user.on('ageChanged', ...) 时,TypeScript 拿 'ageChanged' 去匹配 `${K}Changed`,推断出 K 是 'age',再用 T[K] 查到 number,所以回调参数自动就是 number。拼错的 'agChanged' 推断出的 'ag' 不在 keyof User 里,就会报错。

如果要的是一个事件处理函数的对象,而不是 on 方法,就用前面的 as 写法:

TypeScript
type ChangeHandlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (value: T[K]) => void;
};

const handlers: ChangeHandlers<User> = {
  onNameChange: (value) => console.log(value.toUpperCase()),
  onAgeChange: (value) => console.log(value.toFixed()),
};

以上代码用 TypeScript 7.0.2 在 --strict 下编译通过,带 @ts-expect-error 的行确实报错。

常见追问

`${string}Changed` 这样直接写 string 有什么用?

它表示“任意字符串后面跟着 Changed”,可以用来约束格式而不限定具体值。比如参数类型写成 `${string}Changed`,'fooChanged' 能通过,'foo' 就不行。Handbook 里的事件例子一开始就是用 `${string & keyof Type}Changed` 把事件名限制在已有属性范围内。

模板字面量类型能拼数字吗?

能。插值位置可以放 number、bigint、boolean 等类型,比如 `${number}px` 能匹配 '12px'、'1.5px'。可它只在类型层面检查字符串格式,运行时传进来的值仍然要自己校验。

和映射类型一起用时,怎样按对象联合生成键?

in 后面不一定是键的联合,也可以是对象类型的联合。Handbook 的例子是 [E in Events as E['kind']]: (event: E) => void,遍历每个事件对象,用它的 kind 字段做键名,值的类型里还能用到完整的事件对象。