模板字面量类型和映射类型的 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 由请求方法和路径拼成,方法要求大写:
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 和分布式条件类型如何理解?》,这里看两个拆字符串的例子。
第一个是从路由里取出参数名,让参数对象的键必须和路由一致:
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。所以结果是两个参数名的联合,少写一个就会报错。
第二个是把下划线命名转成驼峰:
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 后面就是新的键名。配合模板字面量类型,可以从一个接口生成另一套名字:
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 时键会被删掉,所以它也能用来过滤属性。比如只保留值为函数的属性:
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 方法带一个泛型,从事件名里推断出属性名:
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 写法:
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字段做键名,值的类型里还能用到完整的事件对象。