怎样手写 MyPick、MyOmit、DeepReadonly、TupleToUnion、First、Awaited 这类类型体操?
类型体操题大多靠三个套路:映射类型遍历键并加减修饰符,T[number] 这类索引访问取出元素类型,条件类型配合 infer 匹配结构再递归。写完要用 @ts-expect-error 和类型相等断言验证。
简答
面试让你手写
MyPick、MyReadonly,其实是在问你会不会“用类型写函数”:输入一个类型,按规则算出另一个类型。常见题目几乎都能归到三个套路里。第一个套路是映射类型,用
[P in K]把一组键挨个走一遍,可以加上或去掉readonly、?,还能用as改名或者把键过滤掉,MyPick、MyOmit、MyReadonly都是这样写的。第二个是索引访问,T[number]能把数组或元组的元素类型取出来,TupleToUnion一行就够。第三个是条件类型加infer,先判断类型长不长某个样子,匹配上了就把其中一块“抠”出来,First、Awaited用的就是它,需要层层展开时再递归调用自己。这些套路在业务里也有用,比如从常量数组生成联合类型、给配置对象加深层只读,都比手写一遍类型更不容易出错。
要注意两点。泛型参数要加上约束,比如
K extends keyof T,否则传错的键不会报错。另外写完一定要验证,用@ts-expect-error标出“这里应该报错”的行,再用类型相等断言比较结果,光看鼠标悬停提示容易漏掉边界情况。
用映射类型写 MyPick、MyOmit、MyReadonly
映射类型就是“遍历一组键,为每个键生成一个属性”。下面三个工具只有遍历的键和修饰符不同:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type MyOmit<T, K extends keyof any> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>;
const preview: TodoPreview = { title: '写周报', completed: false };
// @ts-expect-error 'deadline' 不是 Todo 的键
type Wrong = MyPick<Todo, 'deadline'>;
type NoDesc = MyOmit<Todo, 'description'>;
const noDesc: NoDesc = { title: '写周报', completed: true };
const locked: MyReadonly<Todo> = { title: 'a', description: 'b', completed: false };
// @ts-expect-error 只读属性不能赋值
locked.title = 'c';
先看 MyPick 的约束 K extends keyof T。为什么非加不可?因为 T[P] 要求 P 一定是 T 的键,不加约束就没法写 T[P],而且有了约束,传入 'deadline' 这种不存在的键会直接报错,上面用 @ts-expect-error 标出的那一行就验证了这一点。
MyOmit 换了个思路。它遍历的是 T 的全部键,再用 as 子句给每个键重新算一个名字:要排除的键算成 never,其他键保持原样。Handbook 写明,as 子句算出 never 的键会被丢掉,所以这正是“过滤键”的写法。as 重映射从 TypeScript 4.1 开始支持。K 的约束用 keyof any,也就是 string | number | symbol,这和内置 Omit 一样,允许传入 T 上没有的键。
MyReadonly 只是在每个属性前加了 readonly。映射类型的修饰符还能用 - 去掉,比如 -readonly 去掉只读、-? 去掉可选,内置的 Required 就是 -? 的写法。更多内置工具怎样用来建模接口,可以看《映射类型与 Partial、Pick、Omit 如何用于接口建模?》。
DeepReadonly 为什么要递归,又为什么要放过函数
MyReadonly 只管第一层,config.server.host 照样能改。要让每一层都只读,就要在属性类型是对象时,再对它套一次自己:
type DeepReadonly<T> = T extends (...args: any[]) => any
? T
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
interface Config {
server: { host: string; ports: number[] };
onReady: () => void;
}
declare const config: DeepReadonly<Config>;
// @ts-expect-error 深层属性同样只读
config.server.host = 'localhost';
// @ts-expect-error 数组变成 readonly number[],没有 push
config.server.ports.push(8080);
config.onReady();
读这段代码时会有个疑问:函数也是 object,为什么要先单独判断?如果不先放过函数,函数类型也会被拿去做映射,结果变成一个没有调用签名的对象类型,config.onReady() 就调用不了了。所以判断顺序是:函数原样返回,对象递归映射,其他基本类型直接返回。
数组为什么也跟着变成了只读?TypeScript 3.1 的发布说明写明,[P in keyof T] 这种形式的映射类型作用在数组或元组上时,结果仍是数组或元组,readonly 修饰符让 number[] 变成 readonly number[],上面 push 那一行报错就是证据。
用 T[number] 写 TupleToUnion
索引访问类型和取对象属性一样,只是取的是类型。Handbook 里专门举了用 number 取数组元素类型的例子:
type TupleToUnion<T extends readonly unknown[]> = T[number];
const roles = ['admin', 'editor', 'viewer'] as const;
type Role = TupleToUnion<typeof roles>;
const current: Role = 'editor';
// @ts-expect-error 'guest' 不在元组里
const guest: Role = 'guest';
为什么要写 as const?没有它,roles 会被推断成 string[],T[number] 取出来就只是 string。加上以后它成了只读元组 readonly ['admin', 'editor', 'viewer'],每个元素都是字面量类型,取出来才是三个字符串组成的联合类型。也正因为它是只读元组,约束要写成 readonly unknown[],写成 unknown[] 会拒绝只读元组。
这个写法在业务里很常用:权限、状态这类取值只维护一份常量数组,运行时可以遍历它,类型也从它生成,两边不会对不上。
用 infer 写 First 和 Awaited
条件类型就是类型层面的三元表达式。infer 只能写在 extends 右边,意思是“如果匹配上了,这一块是什么类型,就把它叫作某个名字”:
type First<T extends readonly unknown[]> = T extends [infer F, ...unknown[]] ? F : never;
type F1 = First<[3, 2, 1]>;
type F2 = First<[]>;
const one: F1 = 3;
const isNever: [F2] extends [never] ? true : false = true;
type MyAwaited<T> = T extends PromiseLike<infer V> ? MyAwaited<V> : T;
type A1 = MyAwaited<Promise<Promise<number>>>;
type A2 = MyAwaited<boolean | Promise<string>>;
const a1: A1 = 1;
const a2: A2[] = [true, 'ok'];
// @ts-expect-error A1 是 number,不能放 Promise
const bad: A1 = Promise.resolve(1);
First 拿 T 去比“第一个元素是 F、后面随便”的元组。空元组匹配不上,就走到 never。为什么不直接写 T[0]?因为空元组上 T[0] 会报错,而条件类型能把空元组单独处理掉。检查 F2 时把它包进方括号,是因为 never 在条件类型里会被当成空联合类型,直接分发成 never,原因见《条件类型、infer 和分布式条件类型如何理解?》。
MyAwaited 先判断 T 是不是一个“有 then 方法、能取出结果类型”的对象,PromiseLike 就描述了这种形状。匹配上就把结果类型 V 取出来,再对 V 调用自己,所以 Promise<Promise<number>> 会一层层剥到 number。传入联合类型时,条件类型会对每个成员分别计算再合并,所以 boolean | Promise<string> 得到 boolean | string。这和 Handbook 里内置 Awaited(TypeScript 4.5 加入)给出的三个例子结果一致。内置版本比这里更周全,还处理了 null、undefined 和 then 的参数形状,业务代码里直接用内置的 Awaited 就行。
写完怎样验证
类型代码不会运行,所以“看起来对”很容易骗过自己。最简单的验证是在应该报错的地方写 // @ts-expect-error:如果那一行不再报错,编译器会反过来报 “Unused '@ts-expect-error' directive”,测试就失败了。上面每段代码都按这个方法用 TypeScript 7.0.2 的 tsc --noEmit --strict 编译通过。
判断“结果是不是恰好等于某个类型”,可以用下面这个常见的相等判断写法:
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
type MyAwaited<T> = T extends PromiseLike<infer V> ? MyAwaited<V> : T;
type Cases = [
Expect<Equal<MyAwaited<Promise<Promise<number>>>, Awaited<Promise<Promise<number>>>>>,
Expect<Equal<MyAwaited<boolean | Promise<string>>, boolean | string>>,
];
它比 X extends Y 更严格,比如 any 和 string 用 extends 互相都能通过,用它就会判为不相等。项目里系统地做类型测试,可以看《TypeScript 复杂类型应该怎样测试?》。
常见追问
MyOmit 能不能用 Pick 和 Exclude 写?
能,写成
Pick<T, Exclude<keyof T, K>>:先用Exclude从全部键里去掉要排除的键,再用Pick选出剩下的。它和用as过滤键的写法结果一样,as写法不依赖其他工具,适合面试时从零写出来。
DeepReadonly 处理 Map、Set 吗?
上面的写法不处理。
Map和Set的内容是通过set、add这些方法改的,映射属性加readonly拦不住方法调用。需要的话要单独判断,把Map<K, V>换成ReadonlyMap<K, V>、Set<T>换成ReadonlySet<T>,这两个都是标准库自带的只读接口。
类型体操写得越复杂越好吗?
不是。递归和多层条件类型会让类型和报错信息都很难读懂。业务里优先用内置工具类型,只有在能明显减少重复、防止两处定义对不上时才自己写,写了就配上类型测试。