DrillLab
第 24 / 25 节LESSON 24 / 25约 28 分钟~28 min

Utility Types:会用,还要会手写Utility types: use them, and write them yourself

Partial / Pick / Omit / Record 怎么选,mapped type 手写 MyPick 与 MyPartial,conditional type 配 infer 手写 MyReturnType。How to choose between Partial / Pick / Omit / Record, how to write MyPick and MyPartial as mapped types, and how to write MyReturnType with a conditional type and infer.

1 个练习1 exercises面试 · 第 9 部分Interview · Part 9
这一页有什么On this page5
学完这节你会After this lesson you can
  • 在 patch 参数、props 裁剪、字典三个场景里说出该用哪个 utility type,以及为什么不用索引签名Say which utility type fits each of three cases: a patch argument, trimming props, and a dictionary — and why an index signature is not the answer
  • 手写 MyPartial 与 MyPick,并逐符号解释 { [K in keyof T]?: T[K] }Write MyPartial and MyPick by hand, and explain every symbol in { [K in keyof T]?: T[K] }
  • 用 Pick 加 Exclude 组合出 Omit,并说出官方 Omit 的约束宽在哪里Build Omit out of Pick and Exclude, and say where the built-in Omit has a looser constraint
  • 解释条件类型的分配律,并用 infer 手写 MyReturnTypeExplain how a conditional type distributes over a union, and write MyReturnType with infer
这在考试里考什么What the exam does with this

senior 面试几乎不问「Partial 是什么」,问的是「Partial 怎么实现」。会不会 mapped type 和 conditional type,是「用过 TS」和「懂 TS」的分界线 —— 这三道题就压在这条线上。Senior interviews rarely ask what Partial is. They ask how Partial is implemented. Whether you can write a mapped type and a conditional type is the line between having used TypeScript and understanding it. These three questions sit on that line.

§01

Partial、Required、Pick、Omit、Record 分别解决什么问题What problem does each of Partial, Required, Pick, Omit and Record solve?

What problems do Partial, Required, Pick, Omit and Record each solve

一句话:五个都是「按规则从已有类型造新类型」的 工具类型(utility types):Partial 全变可选、Required 全变必填、Pick 只留白名单、Omit 去掉黑名单、Record 按键集合造字典。 考点不是背 API,是看到场景知道伸手拿哪个。

三个高频场景:

  • 更新函数的 patch 参数 → Partial<User>调用方只传要改的字段,但传了的字段类型必须对, 拼错字段名照样编译报错 (Object literal may only specify known properties)。 这是它比 Record<string, unknown> 强的地方: 可选,但没有放弃字段级检查。
  • 从大类型裁 props → Pick<User, "name" | "role">组件只声明真正用到的字段,User 以后加字段不会波及它。 反方向的「去掉不该外泄的字段」用Omit<User, "email">
  • 键集合已知的字典 → Record<Theme, string>, 别用索引签名。键是字面量联合(literal union)时,Record 的键集合是封闭的: 少一个键、多一个键都是编译错误。 索引签名对任何 string 键都放行,还谎称读出来的值一定存在。

Pick 和 Omit 怎么选:白名单还是黑名单。 类型以后会加字段时,Omit 会把新字段自动带进来 —— 透传配置时这是优点,做脱敏时这是漏洞。 所以对外暴露的公开类型建议用 Pick: 新加的敏感字段默认不外泄。

会追问:「Partial 是深的还是浅的?」—— 浅的, 只动第一层,嵌套对象内部照样必填;要深的得自己写递归的 mapped type。 「Record<string, T> 和索引签名什么区别?」—— 几乎等价,但 Record 的键能用字面量联合,索引签名不行。 答完这两问,多半会接一句「那 Partial 自己怎么实现?」—— 那就是下一题。

In one line: all five are utility types — they build a new type from an existing one by a rule: Partial makes everything optional, Required makes everything required, Pick keeps a whitelist, Omit drops a blacklist, Record builds a dictionary from a key set. The real question is not the API — it is knowing which one a scenario calls for.

Three scenarios that keep coming up:

  • The patch parameter of an update function → Partial<User>. Callers pass only the fields they change, but every field they do pass is still checked — a typo still fails to compile (Object literal may only specify known properties). That is what makes it stronger than Record<string, unknown>: optional, without giving up per-field checking.
  • Trimming props from a big type → Pick<User, "name" | "role">. The component declares only the fields it actually uses, so adding fields to User later cannot ripple into it. The opposite direction — dropping fields that must not leak — is Omit<User, "email">.
  • A dictionary with a known key set → Record<Theme, string>, not an index signature. With a literal union as the key, Record is a closed key set: one key missing or one key extra is a compile error. An index signature waves any string through, then claims the value definitely exists.

Choosing between Pick and Omit: whitelist or blacklist. When the type will grow, Omit silently carries every new field along — a feature when forwarding config, a hole when redacting. So for public-facing types, prefer Pick: a newly added sensitive field stays private by default.

Follow-up: “Is Partial deep or shallow?” — shallow. It only touches the first level; properties inside nested objects stay required. A deep version means writing a recursive mapped type yourself. “What is the difference between Record<string, T> and an index signature?” — almost none, except Record keys can be literal unions and index signatures cannot. Answer both, and the next question is usually “so how is Partial itself implemented?” — which is exactly the next card.

TypeScript三个场景各拿哪个示意Illustrative
1interface User {
2 id: number;
3 name: string;
4 email: string;
5 role: "admin" | "member";
6}
7
8// Partial<T>:patch 的每个字段都可以不传,但传了就必须对
9function updateUser(user: User, patch: Partial<User>): User {
10 return { ...user, ...patch };
11}
12
13declare const user: User;
14updateUser(user, { name: "Ada" }); // ✓ 只传要改的字段
15// updateUser(user, { nmae: "Ada" }); // ✗ 拼错字段名,编译报错
16
17// Pick<T, K>:从大类型裁出组件真正用到的字段
18type UserCardProps = Pick<User, "name" | "role">;
19
20// Omit<T, K>:去掉不该外泄的字段
21type PublicUser = Omit<User, "email">;
1interface User {
2 id: number;
3 name: string;
4 email: string;
5 role: "admin" | "member";
6}
7
8// Partial<T>: every field in patch may be left out, but if given it must be right
9function updateUser(user: User, patch: Partial<User>): User {
10 return { ...user, ...patch };
11}
12
13declare const user: User;
14updateUser(user, { name: "Ada" }); // ✓ pass only the fields you change
15// updateUser(user, { nmae: "Ada" }); // ✗ field name misspelt, compile error
16
17// Pick<T, K>: cut the fields a component really uses out of a bigger type
18type UserCardProps = Pick<User, "name" | "role">;
19
20// Omit<T, K>: drop the fields that must not leak out
21type PublicUser = Omit<User, "email">;
TypeScriptRecord vs 索引签名示意Illustrative
1type Theme = "light" | "dark";
2
3// Record + 字面量联合:键集合是封闭的
4const themeColor: Record<Theme, string> = {
5 light: "#ffffff",
6 dark: "#1a1a1a",
7};
8// 少写 dark、多写 blue,都是编译错误
9
10// 索引签名:任何 string 键都「合法」
11const loose: { [key: string]: string } = { light: "#ffffff" };
12loose["drak"]; // 编译器放行,类型还谎称是 string —— 运行时是 undefined
1type Theme = "light" | "dark";
2
3// Record plus a literal union: the set of keys is closed
4const themeColor: Record<Theme, string> = {
5 light: "#ffffff",
6 dark: "#1a1a1a",
7};
8// Leaving out dark, or adding blue, is a compile error either way
9
10// An index signature: any string key is "valid"
11const loose: { [key: string]: string } = { light: "#ffffff" };
12loose["drak"]; // The compiler allows it and claims string — at runtime it is undefined
§02

手写 MyPick 和 MyPartialHow do you write MyPick and MyPartial by hand?

Implement Pick and Partial by hand

一句话:映射类型(mapped type)是 「对属性名的联合做一次循环」。{ [K in keyof T]?: T[K] } 这一行就是 Partial 的完整实现:逐个取出 T 的属性名,抄下原类型, 加上可选修饰符。

逐段拆这一行:

  • keyof T —— 属性名的联合。User 的就是"id" | "name" | "email"
  • [K in keyof T] —— 映射本体: 对联合里的每个成员各生成一个属性,K 是循环变量。
  • ?: —— 给生成的属性加可选修饰符。 同一个位置还能写 readonly;写成-? 是反向操作 —— 去掉可选,这正是Required 的实现。
  • T[K] —— 索引访问类型(indexed access type): 原属性是什么类型,就抄什么类型。

MyPick 多一个泛型约束(generic constraint):K extends keyof T。没有它,调用方可以给 K 传任何东西,T[P] 也就没法算。有了它,MyPick<User, "age"> 在调用处直接报Type '"age"' does not satisfy the constraint, 而不是悄悄得出一个错的类型。

会追问:「Omit 怎么用 Pick 组合出来?」——Pick<T, Exclude<keyof T, K>>: 先用 Exclude 从全部键里去掉 K,剩下的交给 Pick。 能再补一句「官方 Omit 的约束是 keyof any, 比 keyof T 宽,所以官方版允许去掉一个不存在的键」, 说明你真读过 lib.es5.d.ts —— 背 API 的人和读过实现的人,在这一问上分开。

In one line: a mapped type is one loop over a union of property names. The single line { [K in keyof T]?: T[K] } is the whole implementation of Partial: take each property name of T, copy its original type, add the optional modifier.

Reading it piece by piece:

  • keyof T — the union of property names. For User that is "id" | "name" | "email".
  • [K in keyof T] — the mapping itself: generate one property per member of the union, with K as the loop variable.
  • ?: — add the optional modifier to the generated property. The same slot also takes readonly; and -? goes the other way — it removes optionality, which is exactly how Required is implemented.
  • T[K] — an indexed access type: whatever type the original property had, copy it.

MyPick needs one extra generic constraint: K extends keyof T. Without it, callers may pass anything as K, and T[P] cannot be computed. With it, MyPick<User, "age"> fails right at the call site with Type '"age"' does not satisfy the constraint, instead of quietly producing a wrong type.

Follow-up: “How do you build Omit out of Pick?” — Pick<T, Exclude<keyof T, K>>: Exclude removes K from the full key set, Pick keeps the rest. Add that the official Omit constrains K to keyof any — looser than keyof T, so the official one lets you omit a key that does not exist — and it shows you have actually read lib.es5.d.ts. People who memorized the API and people who read the implementation part ways on this one question.

TypeScriptMyPartial 与 MyPick示意Illustrative
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7// MyPartial:把每个属性抄一遍,顺手加上 ?
8type MyPartial<T> = {
9 [K in keyof T]?: T[K];
10};
11
12// 逐段读:
13// keyof T 属性名的联合:"id" | "name" | "email"
14// [K in ...] 映射:对联合里的每个成员各生成一个属性,K 是循环变量
15// ?: 给生成的属性加可选修饰符
16// T[K] 索引访问:原属性是什么类型,就抄什么类型
17
18// MyPick:只保留 K 里列出的属性
19type MyPick<T, K extends keyof T> = {
20 [P in K]: T[P];
21};
22
23type Draft = MyPartial<User>;
24// { id?: number; name?: string; email?: string }
25
26type Card = MyPick<User, "id" | "name">;
27// { id: number; name: string }
28
29// MyPick<User, "age"> 在这一行就报错,而不是悄悄得到一个错的类型
1interface User {
2 id: number;
3 name: string;
4 email: string;
5}
6
7// MyPartial: copy every property and add ? along the way
8type MyPartial<T> = {
9 [K in keyof T]?: T[K];
10};
11
12// Read it piece by piece:
13// keyof T the union of property names: "id" | "name" | "email"
14// [K in ...] mapping: one property per member of the union, K is the loop variable
15// ?: adds the optional modifier to each generated property
16// T[K] indexed access: copy whatever type the original property had
17
18// MyPick: keep only the properties listed in K
19type MyPick<T, K extends keyof T> = {
20 [P in K]: T[P];
21};
22
23type Draft = MyPartial<User>;
24// { id?: number; name?: string; email?: string }
25
26type Card = MyPick<User, "id" | "name">;
27// { id: number; name: string }
28
29// MyPick<User, "age"> fails on this line, instead of quietly giving a wrong type
TypeScript追问:Omit 怎么组合出来示意Illustrative
1// 追问的标准答案:Omit = 先算键的补集,再 Pick
2type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
3
4interface User {
5 id: number;
6 name: string;
7 email: string;
8}
9
10type NoEmail = MyOmit<User, "email">;
11// { id: number; name: string }
12
13// 官方 Omit 的约束更宽:K extends keyof any(即 string | number | symbol)。
14// 所以 Omit<User, "notAKey"> 合法,MyOmit<User, "notAKey"> 报错。
1// The expected answer to the follow-up: Omit is Pick over the remaining keys
2type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
3
4interface User {
5 id: number;
6 name: string;
7 email: string;
8}
9
10type NoEmail = MyOmit<User, "email">;
11// { id: number; name: string }
12
13// The built-in Omit has a looser constraint: K extends keyof any (string | number | symbol).
14// So Omit<User, "notAKey"> is allowed, while MyOmit<User, "notAKey"> is an error.
§03

Exclude、Extract、ReturnType 是怎么实现的How are Exclude, Extract and ReturnType implemented?

How are Exclude, Extract and ReturnType implemented

一句话:三个都是条件类型(conditional type)—— 类型层面的三元表达式 T extends U ? X : Y。 Exclude 和 Extract 靠它的分配律(distributive)过滤联合; ReturnType 再加上 infer,从函数类型里拆出返回值。

分配律:T 是裸类型参数、 实参又是联合时,条件类型不把联合当整体判断, 而是逐个成员代入、再把结果并起来。Exclude<"a" | "b" | "c", "a"> 因此展开成三次判断: 命中的变成 never,而 never 是空联合, 并进结果就消失了。「过滤」的本质,是「把不要的变成 never」。

infer:extends 右边的模式里挖一个洞, 匹配成功时编译器负责把洞填上。T extends (...args: any[]) => infer R ? R : never里的 R 就是那个洞 —— T 匹配到函数类型时, R 被填成它的返回类型。同一招能拆数组元素、Promise 的值、 函数参数(Parameters 就是把洞挖在参数位置)。

会追问:「怎么关掉分配律?」—— 两边包一层元组:[T] extends [U],T 不再是裸的,联合就被当成整体。 「Exclude<T, T> 是什么?」——never,每个成员都被自己命中。 「never 传进去呢?」—— 还是 never:空联合循环零次。 三问全是分配律的推论,吃透一条规则就全能答。

In one line: all three are conditional types — a ternary at the type level, T extends U ? X : Y. Exclude and Extract filter unions through its distributive behavior; ReturnType adds infer to pull the return type out of a function type.

Distributivity: when T is a bare type parameter and the argument is a union, the conditional does not judge the union as a whole — it substitutes each member separately and unions the results. Exclude<"a" | "b" | "c", "a"> therefore expands into three checks: matching members become never, and never is the empty union, so it vanishes when merged back in. Filtering really means turning the unwanted members into never.

infer: it digs a hole in the pattern to the right of extends, and the compiler fills the hole when the match succeeds. In T extends (...args: any[]) => infer R ? R : never, R is that hole — when T matches a function type, R gets its return type. The same trick unpacks array elements, the value inside a Promise, or function parameters (Parameters is the same hole dug at the parameter position).

Follow-up: “How do you switch distributivity off?” — wrap both sides in a tuple: [T] extends [U], T is no longer bare, so the union is judged as one piece. “What is Exclude<T, T>?” — never: every member is matched by itself. “And feeding never in?” — still never: the empty union loops zero times. All three are corollaries of one rule — master distributivity and the whole set falls out.

TypeScript分配律:过滤联合示意Illustrative
1// 条件类型:类型层面的三元表达式
2type IsString<T> = T extends string ? true : false;
3type A = IsString<"hi">; // true
4type B = IsString<42>; // false
5
6// 分配律:裸类型参数遇到联合,逐个成员代入,再把结果并起来
7type NoA = Exclude<"a" | "b" | "c", "a">;
8// = ("a" extends "a" ? never : "a")
9// | ("b" extends "a" ? never : "b")
10// | ("c" extends "a" ? never : "c")
11// = never | "b" | "c"
12// = "b" | "c" ← never 是空联合,并进去就消失
13
14// 手写版各一行
15type MyExclude<T, U> = T extends U ? never : T;
16type MyExtract<T, U> = T extends U ? T : never;
1// A conditional type: the ternary expression, at the type level
2type IsString<T> = T extends string ? true : false;
3type A = IsString<"hi">; // true
4type B = IsString<42>; // false
5
6// Distribution: a bare type parameter over a union is applied member by member, then joined
7type NoA = Exclude<"a" | "b" | "c", "a">;
8// = ("a" extends "a" ? never : "a")
9// | ("b" extends "a" ? never : "b")
10// | ("c" extends "a" ? never : "c")
11// = never | "b" | "c"
12// = "b" | "c" ← never is the empty union, so it disappears in a join
13
14// One line each, written by hand
15type MyExclude<T, U> = T extends U ? never : T;
16type MyExtract<T, U> = T extends U ? T : never;
TypeScriptinfer:把返回类型拆出来示意Illustrative
1// infer R:在匹配的模式里挖一个洞,编译器匹配成功时负责填上
2// (lib.es5.d.ts 里的 ReturnType 就是这么写的)
3type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
4
5function getUser() {
6 return { id: 1, name: "Ada" };
7}
8
9type U = MyReturnType<typeof getUser>;
10// { id: number; name: string }
11
12// 同一招能拆任何复合类型
13type ElementOf<T> = T extends (infer E)[] ? E : never;
14type Unwrap<T> = T extends Promise<infer V> ? V : T;
15
16type N = ElementOf<number[]>; // number
17type S = Unwrap<Promise<string>>; // string
1// infer R: leave a hole in the pattern, and the compiler fills it in when the match succeeds
2// (this is exactly how ReturnType is written in lib.es5.d.ts)
3type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
4
5function getUser() {
6 return { id: 1, name: "Ada" };
7}
8
9type U = MyReturnType<typeof getUser>;
10// { id: number; name: string }
11
12// The same move takes apart any composite type
13type ElementOf<T> = T extends (infer E)[] ? E : never;
14type Unwrap<T> = T extends Promise<infer V> ? V : T;
15
16type N = ElementOf<number[]>; // number
17type S = Unwrap<Promise<string>>; // string
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1认出来Spot it认出这个 mapped type 在干什么Work out what this mapped type doesDrillLab 自出Written by DrillLab

面试官给出下面这个类型,问它对 T 做了什么。

An interviewer shows you the type below and asks what it does to T.

TypeScriptMystery.ts示意Illustrative
1type Mystery<T> = {
2 [K in keyof T]-?: T[K];
3};
先选一个选项Pick an option first
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

更新函数要接「只改几个字段」的参数An update function takes an argument that changes only a few fields
Partial<T> 当 patch 类型:可选,但保留字段级检查Partial<T> as the patch type: every field optional, but each field still checked
组件只用到大类型的几个字段A component uses only a few fields of a large type
Pick 白名单;对外脱敏优先 Pick 而不是 OmitPick as an allow list; when hiding fields from the outside, prefer Pick over Omit
键是有限集合的字典A dictionary whose keys are a fixed set
Record<字面量联合, V>,少键多键都在编译期报错Record<union of literals, V>; a missing key and an extra key are both compile errors
被问「XX 工具类型怎么实现」Asked how some utility type is implemented
mapped type 循环属性;conditional type 配 infer 拆结构A mapped type loops over properties; a conditional type with infer pulls a type apart
这节的要点What to take away
  1. 五个 utility type 对应五种属性集合操作:变可选、变必填、留白名单、去黑名单、按键集合造字典。The five utility types are five operations on a set of properties: make optional, make required, keep an allow list, drop a deny list, and build a dictionary from a key set.
  2. Partial 是浅的:只动第一层,嵌套对象内部照样必填。Partial is shallow: it only changes the top level, so fields inside a nested object stay required.
  3. mapped type 一行四件事:keyof T 取键、in 循环、?/readonly/-? 改修饰符、T[K] 抄类型。A mapped type does four things in one line: keyof T lists the keys, in loops over them, ?/readonly/-? change the modifiers, and T[K] copies the type.
  4. MyPick 的 K extends keyof T 是泛型约束,把传错键的错误挡在调用处。In MyPick, K extends keyof T is a generic constraint; it reports a wrong key at the call site.
  5. Omit = Pick<T, Exclude<keyof T, K>>;官方 Omit 的 K 约束是 keyof any,比 keyof T 宽。Omit = Pick<T, Exclude<keyof T, K>>; the built-in Omit constrains K to keyof any, which is looser than keyof T.
  6. 分配律:裸类型参数遇到联合就逐成员求值再并;never 是空联合,并进去就消失。Distribution: when a bare type parameter meets a union, each member is evaluated separately and the results are joined; never is the empty union, so it disappears from the join.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises1 个,就在这一页上面 —— 别攒着最后一起做1 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson泛型与收窄:把 any 赶出代码Generics and narrowing: getting any out of the code
    下一节Next lesson
  3. 可选:再巩固一下Optional: reinforce it这一节的 3 道八股3 questions from this lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 异步与结构:Promise.all、EventEmitter、LRUAsync and structure: Promise.all, EventEmitter, LRU