前端知库

怎样手写 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

映射类型就是“遍历一组键,为每个键生成一个属性”。下面三个工具只有遍历的键和修饰符不同:

TypeScript
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 照样能改。要让每一层都只读,就要在属性类型是对象时,再对它套一次自己:

TypeScript
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 取数组元素类型的例子:

TypeScript
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 右边,意思是“如果匹配上了,这一块是什么类型,就把它叫作某个名字”:

TypeScript
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 编译通过。

判断“结果是不是恰好等于某个类型”,可以用下面这个常见的相等判断写法:

TypeScript
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>,这两个都是标准库自带的只读接口。

类型体操写得越复杂越好吗?

不是。递归和多层条件类型会让类型和报错信息都很难读懂。业务里优先用内置工具类型,只有在能明显减少重复、防止两处定义对不上时才自己写,写了就配上类型测试。