对象 — 交叉类型、interface 继承与工具类型¶
这一章讲三件事: 怎么把两个对象类型拼成一个(交叉类型 & 与 interface extends,以及为什么选后者); 键不固定的对象怎么保持类型安全(索引签名与 Record);以及四个内置「工具类型」 (Partial、Required、Pick、Omit)怎么对已有类型做增删改——包括它们一个著名的坑。 读完你会拥有对象类型的完整兵器架,以及 type 与 interface 之争的一个明确答案。
1. 顶层全景¶
组合两个类型: A & B(交叉) interface B extends A(继承)
键不固定的对象: [key: string]: T(索引签名) Record<K, T>
对已有类型做变换: Partial<T> 全可选 | Required<T> 全必填
Pick<T, K> 只留这些 | Omit<T, K> 去掉这些
图说:三行分别是「拼」「兜」「改」。本章主线例子会把三行各走一遍。
读这张全景表时,先注意一个容易被「工具感」盖过的事实: 三行工具没有一行发明新的类型语义——交叉的属性合并、索引签名的任意键、 Partial 的可选化,全部是第 04 章对象类型语法的参数化复用。 本章的增量不是「新规则」,是「新写法」:把「拼形状、兜动态、做变换」这三件事 从「手写一遍」升级成「一行声明」。所以本章的正确读法是「工具速通」: 不必逐字记忆每个工具的语法(速查卡会兜着),要建立的是「遇到这三类需求时, 知道有对应的一行写法」的条件反射——剩下的,用的时候查卡即可。
2. 交叉类型:& 把两个形状叠在一起¶
解决什么问题: 已经有 Album(唱片)和 SalesData(销量)两个类型,
现在想要「一张唱片的销量数据」——五个属性全都要。复制粘贴五个属性太蠢,交叉类型一行解决:
type Album = {
title: string;
artist: string;
releaseYear: number;
};
type SalesData = {
unitsSold: number;
revenue: number;
};
type AlbumSales = Album & SalesData;
const wishYouWereHereSales: AlbumSales = {
title: "Wish You Were Here",
artist: "Pink Floyd",
releaseYear: 1975,
unitsSold: 13000000,
revenue: 65000000,
};
& 读作「与」:是 |(或)的反义词——并集说「要么这样要么那样」,
交叉说「既是这个又是那个」2。合出来的类型要求两个来源的全部属性,
少一个都不履约3。还可以三连:Album & SalesData & {genre: string}4。
并集与交叉的「方向感」¶
初学时 & 和 | 最容易混,一个判别抓手是结果的宽度:
并集(A | B):结果比 A、B 各自都宽 —— 「更多种可能的值」
交叉(A & B):结果比 A、B 各自都窄 —— 「更苛刻的要求,能过关的值更少」
具体感受:能赋给 A & B 的值,必须同时满足两个类型的全部要求——
要求叠要求,合格者自然变少;能赋给 A | B 的值,满足任何一个就行——
门槛低,合格的多。
图说:「与」收窄(值要的东西更多),「或」放宽(值要的东西更少)。
记反了,一切联合/交叉的推理都会反——这个方向感是全章的地基。
这个方向感在第 13 章还会再受一次检验:那里会见到「函数联合收成交集」的 怪癖——看似矛盾,实则正是「参数方向的宽窄与返回值方向相反」的一次重演。 地基打正,后面的反常都能追回家。
还有一个「方向感」的日常应用:读别人的交叉/联合类型时,先判断它声明的是
「值的集合」还是「要求的清单」。Config | Defaults 读作「配置可以来自
任何一边」(值的两条路);Config & Validated 读作「配置必须同时满足两边」
(要求的两道关)。把两类读法混用,是联合类型误用率常年冠军的常见成因——
第 06 章会完整展开这条战线。
交叉的适用域:& 与 extends 的分工预告¶
交叉和 interface extends 干的是同一件事(把形状合并),但适用域不同, 值得在使用前先划清:
交叉(&)的主场:
· 合并两个「现成的」type 别名(别名不能被 extends)
· 临时拼装:函数参数处写 (A & B),用完即弃
· 叠加修饰:Base & { id: string } 这类「基础 + 补丁」
interface extends 的主场:
· 有层级语义的继承(子类型是父类型的一种,第 09 章展开)
· 需要声明合并(同名接口自动合流)的场景
· 报错时机和性能有要求的对象(见下一节两个硬理由)
图说:& 是「平级拼装」,extends 是「层级派生」;
平级拼装用完即弃,层级派生会被子类型、实现类持续引用。
这条分工也解释了下一节的原书裁决:对象类型的「主户口」放 interface, 交叉留给「拼装现场」。两者不是竞争关系,是一个负责存档、一个负责施工。
交叉的暗面:冲突收编成 never¶
交叉两个对象类型很美好;交叉不相容的东西,结果会变丑。
字符串和数字没有共同值——没有东西「既是字符串又是数字」,所以结果是 never(第 06 章的「不可能」)5。
对象类型也一样,只是更隐蔽:
type User1 = {age: number};
type User2 = {age: string};
type User = User1 & User2;
// 悬停 User → { age: never }
age 不可能既是 number 又是 string,于是被解析成 never6。
记住这个症状的的样子:交叉的结果里冒出 never,说明两个来源在同一个属性上打架。
诊断信息不吵不闹,但属性实际上永远没法被满足——这个坑下一节就有对症的药。
never 的三步排查法:交叉冒出的 never 从哪来¶
交叉结果里出现 never,排查可以按固定顺序走三步:
第 ① 步:悬停交叉结果的展开形状,找到 never 所在的属性名
第 ② 步:回到两个(或多个)来源类型,分别看那个属性声明成了什么
第 ③ 步:冲突定了——决定哪一边是对的(或两个来源本就不该交叉)
图说:三步全是悬停,没有猜。never 是「值的不可能」,不可能必有两处声明在打架;
找到打架双方,冲突的性质(类型不同?可选性不同?字面量互斥?)一目了然。
冲突的「性质」决定修法,常见的三种和各自的处方:
| 冲突性质 | 例子 | 处方 |
|---|---|---|
| 类型不同 | age: number vs age: string |
二选一;或改并集(number \| string) |
| 可选性不同 | bio?: string vs bio: string |
明确要不要必填,两边对齐 |
| 字面量互斥 | status: "a" vs status: "b" |
通常意味着两个来源不该交叉,该用判别联合(第 06 章) |
第三种尤其值得警惕:字面量互斥的冲突,常常说明你把「或」写成了「与」—— 本想要「状态是 a 或 b 的对象」,交叉却要求「同时是 a 和 b」。 此时正确结构是第 06 章的判别联合,不是调和交叉。这个「或/与混淆」是交叉类型 最常见的误用根源。
3. interface 与 extends:面向继承的另一半拼图¶
解决什么问题: type 语法不是定义对象类型的唯一方式。TypeScript 从第一个版本起就有
interface(接口),语法上只差在:没有等号,关键字换名7:
type Album = {
title: string;
artist: string;
releaseYear: number;
};
interface Album {
title: string;
artist: string;
releaseYear: number;
}
看着像同义词,其实差别很大。interface 的看家本领是 extends——声明继承关系:
interface Album {
title: string;
artist: string;
releaseYear: number;
}
interface StudioAlbum extends Album {
studio: string; // 录音棚
producer: string; // 制作人
}
interface LiveAlbum extends Album {
concertVenue: string;
concertDate: Date;
}
StudioAlbum 自动拥有 Album 的三个属性,再加自己的两个8。
americanBeauty 和 oneFromTheVault 两个实例摆在一起,继承关系一眼可读9。
和交叉一样,extends 也能多继承——逗号分隔即可:
多继承这里有一条「菱形问题」的轻量版值得预防:两个父接口若都带同名属性, extends 会当场拒绝(冲突在定义处现形,正是它比交叉强的地方之一)。 设计多继承时,小接口的命名要带语义(WithId、WithTimestamps), 让「同名冲突」在接口命名阶段就暴露为「语义重复」,而不是留到合并时。
& 还是 extends?原书给了两个硬理由,都选 extends¶
同一个需求(让 B 拥有 A 的属性)有两条语法,原书的裁决相当果断:能用 interface extends 就用, 理由有两个10。
理由一:冲突报错更早、更准。 回想上一节那个 age: number 和 age: string 的冲突——
交叉类型把它静默解析成 never,要等到你使用 age 时才报错;
interface extends 在定义处就拒绝:
interface User1 {
age: number;
}
interface User extends User1 {
age: string; // 红线:Interface 'User' incorrectly extends interface 'User1'.
} // Types of property 'age' are incompatible.
错误的落点从「使用时」提前到「定义时」——型错了在型的门口被拦,而不是等值来了才炸11。
理由二:性能。 交叉类型每次使用都重新计算;interface 的结果可以按名字缓存, 算一次、处处复用。在大项目里,这个差别直接体现在 IDE 的流畅度和 tsc 的检查时间上12。
原书的结论句值得原文记住:「using interface extends is better for catching errors and for TypeScript performance」——更好的错误、更好的性能10。
两个硬理由各自值得再各推一层:
理由一(报错时机)的工程含义:
「定义处报错」的价值不只是提前——它把错误的责任归位了。
交叉类型把冲突拖成 never,等于宣布「这个属性自此作废」,
但「作废」是谁的决定?没人决定,是合并机制的副作用。
interface extends 在定义处拒绝,等于宣布「这个组合不合法,谁写的谁改」。
责任可归属,是报错时机提前之后真正的红利。
理由二(性能)的判断法:
「每次使用都重算」的代价,和类型的使用频率成正比。
一个交叉类型只在一处用,性能差异是零;被 50 个函数引用,
差异就变成 50 次重复计算。判断标准:这个合并结果的**引用次数**。
引用次数 ≥ 2 就该给它名字(而命名 = interface 的势力范围),
这其实是同一个理由的两种说法:缓存的前提是命名。
图说:两个理由合流成一条——「值得重复引用的形状,给它名字和户口(interface);
一次性拼装的,留在现场(&)」。
那为什么不全用 interface?type 与 interface 的对照¶
interface 只擅长对象和函数;type 别名可以命名任何东西——并集、交叉、原始类型,随你13:
interface 还有一个著名的怪癖:声明合并(declaration merging)。 同一个作用域里两个同名 interface 会被自动合并成一个:
interface Album {
title: string;
artist: string;
}
interface Album {
releaseYear: number;
genres: string[];
}
// 实际生效(两个声明被合并):
interface Album {
title: string;
artist: string;
releaseYear: number;
genres: string[];
}
同名 type 则直接报错(duplicate identifier)——「重复定义」这个失误被当场抓住14。
听起来像特性?原书坦白得很:「我们人生的好几个小时,丢在了同一个 2000+ 行的文件里 两个同名 interface 上。」(We have lost hours of our lives...)—— 在 JavaScript 的世界里这行为相当反常,是个 gotcha15。它存在的好理由要到第 10 章 (给第三方库的类型补丁)才揭晓,平时它更多是陷阱。
这个「gotcha 的正当用途在四章之后」的结构,值得作为读书方法单独留一句: 本章有三处「先埋后收」——声明合并(第 10 章)、索引签名的字面量限制(第 16 章)、 DistributiveOmit 的 extends(第 16 章)。读到时不要急着问「为什么不能更聪明」, 记下「此处有后文」即可;全书的章节是按「先给手感、后给机制」排布的, 很多「限制」的真相都在后面等语境齐了再揭盅。这也是原书与工具文档的差别: 文档解释每个特性,教材安排每个特性的出场时机。
所以原书给出了自己的默认(也是社区争论多年的答案):
简单对象类型默认用 type;需要 extends 时再换 interface。 type 更灵活,还不会在深夜偷偷合并你的声明。但这是个接近的抉择—— 从面向对象语言过来的人偏爱 interface,也完全可以理解16。
判断(我们的,不是书里的): 「默认 type、要 extends 再换 interface」是 2026 年社区的主流答案, 但值得想清楚它成立的条件:它默认你更看重灵活性(并集、交叉、模板字面量都只有 type 玩得转) 而不是多人协作下的继承体系。在严格分层的大型后端代码库里,interface 的声明合并 反而是给横切关注打补丁的正规通道(第 10 章)。「默认 type」不是法律,是风格。 如果错,会错在: TypeScript 若在未来版本给 type 也加上受控的合并能力(社区确实反复讨论过), interface 的最后一块专属领地将消失,这个「默认」会更没有争议——但眼下(2026)原书的建议站得住。
4. 动态键:键名事先不知道的对象¶
解决什么问题: JavaScript 里最常见的动作之一:先有空对象,再往上面动态挂键。
// JavaScript:毫无问题
const albumAwards = {};
albumAwards.Grammy = true;
albumAwards.MercuryPrize = false;
同样的代码在 TypeScript 里三连报错:Property 'Grammy' does not exist on type '{}'17。
你可能会不服气:编译器不是会收窄吗,看不出我在加键?它看得出,但它选择保守——
随便允许往对象上加键,等于放弃拼写检查(第 02 章那条「opinionated 规则」的又一体现)。
想动态加键,得先声明「我允许」。
索引签名:先声明「任意字符串键都行」¶
术语先落地:往 albumAwards.Grammy 里写值,这个动作叫 indexing(索引)——
用字符串键 Grammy 索引进对象18。对应的类型语法叫索引签名(index signature):
const albumAwards: {
[index: string]: boolean;
} = {};
albumAwards.Grammy = true; // ✅
albumAwards.MercuryPrize = false; // ✅
读法:[index: string]: boolean = 「用任何字符串键索引进来,拿到的都是 boolean」19。
方括号里的名字只是描述,写成 [iCanBeAnything: string] 也一样20。
interface 和 type 里同样能用(interface AlbumAwards { [index: string]: boolean })21。
索引签名的三个边界,用之前先知道¶
索引签名「任意字符串键都行」的代价,值得在上手前就看全:
边界一:值类型必须统一
[index: string]: boolean 意味着「每个键的值都是 boolean」,
一旦混进 { Grammy: true, year: 1965 },year 报错——
想混装多种值,值得是 unknown 再收窄,或拆成两个对象。
边界二:已知键失去了「必填」保障
对象类型 { Grammy: boolean } 说「Grammy 必须存在」;
索引签名 { [index: string]: boolean } 说「Grammy 可以存在」——
键的存在性从合同降级成可能。需要「某些键必有 + 其余随意」的组合写法,
见下一节的「已知键 + 动态键」。
边界三:键集合不可枚举
索引签名类型的 keyof 是 string——你拿不到「实际有哪些键」的清单
(第 11 章的 keyof 教训)。需要可枚举键集时,Record + 字面量并集键
或 as const 对象才是正解。
图说:索引签名买的是「接纳任意键」,付的是「值的同质性、键的必然性、
键的可枚举性」——三样代价明码标价。
这三个边界决定了索引签名的适用域:键集合开放、值类型单一、不关心枚举的场景 (缓存、字典、计分板)。超出这个三角任何一条,都该考虑 Record 的窄键版 或「已知键 + 动态键」的组合写法——这正是后面两节的内容。
Record 是内置工具类型,两个类型参数:键的类型、值的类型。
和索引签名等价,但更短22。关键差异在键可以是字面量并集——索引签名做不到:
const albumAwards: Record<"Grammy" | "MercuryPrize" | "Billboard", boolean> = {
Grammy: true,
MercuryPrize: false,
Billboard: true,
};
// 索引签名这么写会报错:
{
[index: "Grammy" | "MercuryPrize" | "Billboard"]: boolean;
}
// An index signature parameter type cannot be a literal type or generic type.
// Consider using a mapped object type instead.
报错信息本身就是教材:索引签名不收字面量类型,并提示「用映射对象类型」—— 那是第 16 章的内容,这个限制的底层原因到那时才揭盅23。
原书对 Record 的定位干脆利落:「动态键的首选」(go-to)——它灵活、可读、可复现24。
Record 还有两个速记价值值得点名。其一,并集键的 Record 是最快的「全必填」声明:
Record<"Grammy" | "MercuryPrize" | "Billboard", boolean> 一行等于三个必填属性的
对象类型——写配置表、枚举映射时,比手写接口短一半,而且键清单能复用(第 11 章的
keyof typeof 会从这种对象里反推出键联合)。其二,Record<K, unknown> 是
「形状未知的对象」的诚实标注:比 any 多一层「它至少是个对象」的承诺,
比具体类型少一份「我都知道」的虚张声势——在边界数据(第 05 章的 JSON.parse 一系)
上,它是 any 和精确类型之间的中间档。
已知键 + 动态键的组合¶
真实需求经常是混合的:已知键要有(可能必填),未知键也要放行。两种等价的写法:
// type 版:Record 已知并集 ∩ 索引签名
type BaseAwards = "Grammy" | "MercuryPrize" | "Billboard";
type ExtendedAlbumAwards = Record<BaseAwards, boolean> & {
[award: string]: boolean;
};
// interface 版:extends 已知键 + 索引签名
interface BaseAwards {
Grammy: boolean;
MercuryPrize: boolean;
Billboard: boolean;
}
interface ExtendedAlbumAwards extends BaseAwards {
[award: string]: boolean;
}
两种都能让 {Grammy: true, ..., "American Music Awards": true} 合法——
已知的三个键必填(并集做键的 Record 默认全必填),其余任意字符串键放行25。
原书在两者间再次选了 interface extends:与前面同一套理由(报错时机 + 性能)26。
组合写法的代价:双重语义重叠时的边界情况¶
「已知键 + 动态键」的写法威力大,但有一个值得知道的边界: 已知键的类型必须和动态键的值类型兼容,否则冲突:
interface ExtendedAwards {
Grammy: boolean; // 已知键
[award: string]: number; // 动态键说「所有字符串键的值都是 number」
}
// ❌ Grammy 的 boolean 和索引签名的 number 冲突
索引签名是「所有字符串键」的宣言——已知键既然也是字符串键,就必须服从 索引签名的值类型。想要「已知键 boolean、动态键 number」的混搭, 索引签名做不到(这正是索引签名「值类型必须统一」边界的回声); 能做到的是「已知键更具体、动态键是其父类型」的层级关系:
interface ExtendedAwards {
Grammy: "yes" | "no"; // 更具体的字面量
[award: string]: string; // 动态键宽,兼容具体键
}
// ✅ 已知键的类型是动态键值类型的子集
这个「子集兼容」规则值得记住:组合写法里,已知键的类型必须能塞进 动态键的值类型里——顺序反过来(动态键比已知键更具体)必然报错。 排障「interface extends 后索引签名报错」时,先看这两个声明的值类型是否 父子颠倒。
PropertyKey 是全局类型,代表所有能当键的东西:
键的三种合法类型(字符串、数字、符号)的并集27。它最大的用武之地是给「键」这个参数定型。
练习里的 hasKey 函数:参数 key 从 string 改成 PropertyKey,
字符串键、数字键、符号键的测试就全过了——比手写 string | number | symbol 短,而且语义更准:
「任何合法键」28。看到对象的键类型想写 string | number | symbol,写 PropertyKey。
还有一个值得留意的细节:PropertyKey 的定义不在这本书里,而在 TypeScript 自带的类型文件
lib.es5.d.ts 里——就是那个 declare type PropertyKey = string | number | symbol;27。
「declare」打头的文件是声明文件(.d.ts),内容只有类型、没有实现,整个 JavaScript
世界(包括 Object.hasOwnProperty 的参数类型)都是靠它们才有了类型。
这些文件从哪来、怎么给项目配上,是第 14 章的专题;这里先埋一个印象:
你日常用到的每一个全局类型(Promise、Array、PropertyKey……)都有出处,都住在声明文件里。
悬停追进去看定义,是学习类型系统最被低估的习惯之一。
判断(我们的,不是书里的): 动态键三件套(索引签名 / Record / PropertyKey)是「渐进类型」哲学的好样本: JavaScript 的惯用法(动态挂键)不被禁止,但每一招都要求你显式声明边界—— 键是什么类型、值是什么类型。类型系统的角色是给动态性画跑道,不是拆掉跑道。 如果错,会错在: 对于键极其发散的对象(几百个键的配置表),跑道的维护成本可能超过收益, 这时
Record<string, unknown>加 Zod 校验(第 06 章的动线)反而更诚实—— 「我控制不了键的集合,但我校验值」。
5. 工具类型:对已有类型做变换¶
组合与动态键解决「从哪来」;这一节解决「怎么改」。 四个内置工具类型,名字即用法:Partial 全可选、Required 全必填、 Pick 挑几个留下、Omit 挑几个删掉29。
在逐个讲之前,先把四个工具放进一张「语义坐标」——它们的全部差异, 可以压缩成两个维度:可选性(改修饰符)vs 键集(改成员)、 加宽(放开)vs 收窄(收拢):
改可选性 改键集
加宽(放开) Partial (Omit 删键 = 键集收窄…)
收窄(收拢) Required Pick(留) / Omit(删)
图说:四个工具两两对仗——Partial/Required 是同一操作的两个方向,
Pick/Omit 也是。学一个会一对,这就是它们被设计在一组的原因。
这个坐标还有一个实用推论:「改可选性」的工具天然分配(只动修饰符), 「改键集」的工具天然不分配(要合并键)——上一节的分配性之谜, 在这里有了结构性的解释。不需要背哪个工具分配,记住维度就够了: 动修饰符的,逐成员来;动键的,先合并。
主线例子继续用 Album。四个工具轮流上:
Partial:更新场景的全可选¶
interface Album {
title: string;
artist: string;
releaseYear: number;
genre: string;
}
type PartialAlbum = Partial<Album>;
// { title?: string; artist?: string; releaseYear?: number; genre?: string; }
const updateAlbum = (album: PartialAlbum) => { /* ... */ };
updateAlbum({title: "Geogaddi", artist: "Boards of Canada"}); // 只更新两个字段,合法
「更新」语义天然是部分的:传进来的对象只含要改的字段。
Partial 把每个属性都加上 ?,完美对位30。
把 Partial 放进「更新流」的完整上下文,它的设计意图更清楚:
典型更新链路,三个类型各司其职:
Album → 完整模型(单一事实源,来自第 04 章)
Partial<Album> → 更新载荷(调用方:我只想改这几样)
Required<Album> → 落库前校验(数据层:字段必须齐全)
图说:模型在链条两头的形态由 Partial/Required 这对镜像工具收尾,
中间任何一环想「少传」或「缺字段」,类型层面立刻有对应工具表态。
这个三件套视角也解释了为什么 Partial 和 Required 互为镜像却不同源:
Partial 服务的方向是入口放宽(调用方自由),Required 服务的方向是
出口收紧(存储安全)。一个方向一个工具,合起来覆盖「数据从进到出」的
两种形态要求。练习 6-8(updateProduct 的部分更新)正是这个三件套的中段应用:
Partial<Omit<Product, "id">> —— Omit 把 id 从更新载荷中剔除(身份不能改),
Partial 把剩下的全部放宽(改哪样随意)1。
Required:反向操作,且只有一层¶
重要的边界:Partial 和 Required 都只作用一层。
genre 如果是个嵌套对象,Required<Album> 只会让 genre 本身必填,
genre 里面的 parentGenre、subGenre 依然可选——悬停看得清清楚楚31。
要深度版本,原书指路 type-fest 库(并附了 GitHub 链接)32。
「只一层」这个边界值得和第 08 章的 Readonly 对照着记—— Readonly<T> 同样只锁一层,ReadonlyArray 同样只锁数组本身。 「工具类型默认浅层」是 TypeScript 内置工具的系统性格,原因也很一致: 深版本必须递归,递归在类型层面意味着潜在的无穷展开, 内置工具选择了最安全的形态。需要深版本时的三条路(按成本排): 手写递归映射类型(第 16 章的技能)、type-fest 这类工具库、 或者重新设计数据让深度不需要(扁平化 often 是最优解)。 多数项目走到第二条就停了;第三条常被忽视,却常常最对—— 「需要深度工具」有时只是「嵌套太深」的症状。
Pick 与 Omit:留和删¶
type AlbumData = Pick<Album, "title" | "artist">; // 只留这两个
type AlbumData2 = Omit<Album, "id" | "releaseYear" | "genre">; // 删掉这三个,殊途同归
选型有讲究,原书用一句话说透:
Omit 的对象随源类型长大;Pick 的对象保持不变。 Album 以后加了新属性,Omit<Album, "id"> 自动包含它;Pick<Album, "title"|"artist"> 不会33。
「创建还没有 id 的对象」(比如待入库的新记录)是 Omit 的经典场景:Omit<Album, "id">34。
Omit 的怪癖:删掉不存在的键不报错¶
Pick 则严格得多——挑一个不存在的键,当场报错:
Type '"producer"' does not satisfy the constraint 'keyof Album'35。
一松一严不是疏忽,是当年刻意的决定:TypeScript 团队实现 Omit 时在「严格版 / 宽松版」之间选了宽松版—— 社区当时更流行宽松版;又因为 Omit 是全局类型、人人可用, 宽松版「更兼容、更少意外错误」36。原书附了当年讨论的 GitHub issue 和 PR 链接, 并提醒:宽松的 Omit 多数时候够用,只是报错的方式可能出乎你意料; 严格的版本,第 16 章教你手写37。
联合类型上的大坑:Omit/Pick 不「分配」¶
这是本章技术上最要紧的一段。三个类型并成一个联合,想统一删掉 id:
type Album = { id: string; title: string; genre: string };
type CollectorEdition = { id: string; title: string; limitedEditionFeatures: string[] };
type DigitalRelease = { id: string; title: string; digitalFormat: string };
type MusicProduct = Album | CollectorEdition | DigitalRelease;
type MusicProductWithoutId = Omit<MusicProduct, "id">;
你期待的是「三个成员各自删 id 的新联合」;实际悬停一看:
只剩 title!Omit 先把联合拍平成公共属性(id 和 title 是三个成员仅有的公共属性),
再删掉 id38。对比一下:Partial 和 Required 在联合上行为正常——
逐个成员包一层,联合结构原样保留39。
病根一句话:Omit 和 Pick 不具有分配性(not distributive)—— 遇到联合,它们不逐个成员操作,而是把成员揉成一体再动手40。
药方是用条件类型手写一个分配版(第 16 章会讲 T extends any ? ... : never 就是分配开关):
type DistributiveOmit<T, K extends PropertyKey> = T extends any
? Omit<T, K>
: never;
type MusicProductWithoutId = DistributiveOmit<MusicProduct, "id">;
// ✅ 三个成员各自删掉 id,联合结构完整保留
DistributivePick 同理(K extends keyof T)41。结论:
在联合类型上用 Omit/Pick,需要分配版,否则结果出乎意料42。
这条知识在 2026 年的真实项目里极其高频——判别联合(第 06 章)恰好是最爱用联合的类型形态, 而「给判别联合统一删个 id」又是高频操作。原书把 Omit 的两个坑(宽松键、不分配)放在同一章, 是因为这两处恰好是 Omit 最容易咬人、而报错最不显眼的位置——一个静默放过,一个静默拍平。
分配性的判据:工具会不会「逐个成员操作」¶
什么工具分配、什么不分配,有一个可记的判据:
分配的本质 = 对联合的每个成员各做一次操作,再把结果合回联合。
Partial<T> ✅ 分配:Partial<A|B> = Partial<A> | Partial<B>
Required<T> ✅ 分配(同上,逐成员)
Pick/Omit ❌ 不分配:先拍平公共属性,再统一操作
Record<K, V> N/A(键参数,不操作联合成员)
图说:Partial/Required 是「逐件加工」,Pick/Omit 是「混装处理」——
同一箱水果,逐件擦和倒在一起搅拌,出品完全不同。
为什么 Pick/Omit 不分配?原书没展开,补一句机制层面的解释(补充,来自通用知识):
它们的实现是「把 T 先转成一个完整的键值映射再挑选/删除」——
转映射这一步对联合而言就是「合并公共键」的操作,成员边界在这一步已经消失。
Partial/Required 则只动属性的可选修饰符,不需要转映射,成员边界得以保留。
判断一个新工具分不分配,就看它的实现需不需要「先合并成员」——
需要合并的,都不分配。这也是第 16 章的「分配性开关」(T extends any ? ... : never)
存在的理由:它强制让每个成员单独过一遍操作,绕过合并。
等于给工具类型划清了「哪里别信它」的边界。
四个工具类型速查(本章的口袋卡片)¶
| 工具 | 一句话 | 典型场景 | 本章揭露的边界 |
|---|---|---|---|
Partial<T> |
全属性加 ? |
部分更新(updateAlbum) | 只作用一层,不递归31 |
Required<T> |
全属性摘 ? |
补齐可选对象为完整对象 | 同上,只一层31 |
Pick<T, K> |
只留 K 这些键 | 对外契约、响应投影 | 键不存在会报错;对联合不分配3540 |
Omit<T, K> |
删掉 K 这些键 | 去 id 的新建对象 | 删不存在的键不报错;对联合不分配3640 |
速查表之外还有一条贯穿本章的元规则值得单独记住:
工具类型永远不会修改源类型——Partial<Album> 是一个新类型,Album 自己毫发无损。
所有变换都是「派生新类型」,这正是「单一事实源」能成立的原因:
源头只有 Album 一份,所有变体都是它的纯函数,改源头、变体自动跟随。
第 11 章会把这条元规则推到极限——用 keyof、typeof 让派生连「键名清单」都不用手写。
Pick 和 Omit 的生长方向:源变长时,谁跟着变¶
练习 6-7 的收尾给了速查表之外的一个维度,值得单独立一条: Pick 和 Omit 在「源头加字段」时行为相反:
interface User { id; name; email; role; }
需要「只有 name 和 email」的返回类型:
Pick<User, "name" | "email"> → 源加了 phone 字段 → 返回类型不变(还是两项)
Omit<User, "id" | "role"> → 源加了 phone 字段 → 返回类型自动多一个 phone
interface extends 拆分 → 同 Pick:NameAndEmail 固定两项
图说:Pick 是「白名单」——源再怎么长,我只认这几个;
Omit 是「黑名单」——源长出来的新东西,自动进我的集合。
原书给的选择依据不是「哪个更高级」,而是需求语义: 「这个接口就是这两个字段」(对外契约)选 Pick 或 extends 拆分——新字段不该自动混入; 「这个接口是『除了某几个之外的全部』」(排除敏感字段)选 Omit——新字段应该自动跟来59。 选错的代价也有方向性:该用 Pick 用了 Omit,契约会在源扩张时悄悄变宽; 该用 Omit 用了 Pick,新字段永远漏不出去。两个方向的事故都不报错—— 这是工具选型里少数「没有编译器兜底、全靠语义判断」的决策点,值得在团队里统一口径。
6. 组合技:工具类型的套娃¶
练习里的两道题展示了工具类型如何组合,正好收束主线例子:
// 更新产品:去掉 id,且所有字段可选
const updateProduct = (
id: number,
productInfo: Partial<Omit<Product, "id">>,
) => { /* ... */ };
读法从内向外:先 Omit<Product, "id">(更新的不该带主键),再 Partial<...>(改哪几个随你)。
调用方只传 {name: "Book"}、{price: 12.99} 或任意组合,全部合法43。
另一道题是 Pick/Omit/interface 三条路线各解一遍「fetchUser 只返回 name 和 email」, 并点破三者的成长方向:Omit 版随 User 长大,Pick 版和 interface 版保持不变—— 选哪条,取决于你希望这个类型跟着源类型演化,还是冻结44。
套娃的读法与写法纪律¶
组合技一旦开始套娃,读写两端都需要一点纪律:
读(遇到别人的套娃):
从内向外逐层剥——Partial<Omit<Product, "id">> 先想「去掉 id 的 Product」,
再想「全可选版的它」。每层停在悬停上验证一次,别一口气读到最外层。
写(自己制造套娃):
从外向内设计——先定「这个参数的最终语义是什么」(部分更新的载荷),
再选最后一步的工具(Partial),往里填它的输入(Omit<Product, "id">)。
两层是舒适区,三层以上请拆中间类型。
图说:读和内向外,写和外向内——方向相反,都符合各自的心智负荷。
这条读写纪律和第 11 章「套娃超过两层就拆中间类型」完全同构—— 类型表达式的复杂度管理和值表达式一模一样,只是类型没有运行时性能压力, 更容易被写成「炫技式长表达式」。团队 code review 时见到三层以上套娃, 第一个问题该是「为什么不拆」,而不是「它算得对不对」。
样本走查一:BaseEntity 的两代写法(交叉 → extends)¶
练习 6-1/6-2 是同一次重构的前后两半,把「为什么 extends 更好」演成了活剧。
起点:User 和 Product 都各自带着 id、createdAt 两个字段,重复了。
第一代(交叉): 抽出 BaseEntity,用 & 叠回去:
type BaseEntity = {
id: string;
createdAt: Date;
};
type User = {
name: string;
email: string;
} & BaseEntity;
type Product = {
name: string;
price: number;
} & BaseEntity;
行为正确,重复消失46。
第二代(extends): 换成 interface 体系:
interface BaseEntity {
id: string;
createdAt: Date;
}
interface User extends BaseEntity {
name: string;
email: string;
}
interface Product extends BaseEntity {
name: string;
price: number;
}
结构相同,但报错时机和性能都换了档(第 3 节的两条理由)。
练习的附加题还展示了多继承的拆法:把 id 和 createdAt 再拆成
WithId、WithCreatedAt 两个小接口,interface User extends WithId, WithCreatedAt——
「你的 TypeScript 编译器会感谢你的」(原书原话)47。
这个拆法的价值在于语义粒度:不是每个带 id 的东西都带 createdAt。 小接口像乐高积木,组合而非堆积——这是 interface 体系真正的用法, 而不是「把大类型切成碎片的强迫行为」。
两代写法的「悬停对比实验」¶
两代写法的差异,最直观的验证方式是同一次悬停:
悬停第一代(交叉)的 User:
→ 显示 A & B 的展开形状(属性全在,但交叉结构可见)
悬停第二代的 User:
→ 显示扁平的成员列表,BaseEntity 的存在被「消化」了
再看 IDE 行为:
交叉版:Go to Definition 跳到交叉表达式,要再跳一次才到 BaseEntity
extends 版:直接跳到 interface User,成员的来源一眼分清
图说:extends 把「继承」消化进类型的身份,交叉把「组合」留在类型的表面——
这个差别平时无害,调试和重构时天天有感。
这个实验也是「报错时机」差异的体感来源:交叉版的冲突要等使用才炸, extends 版的冲突在定义处就拒——两代写法跑同一段错误代码, 红线的位置差着十万八千里(第 3 节的核心理由,在此有了可触摸的形态)。 做题时如果两代写法都写了,这个悬停对比值得亲手做一遍—— 一分钟的实验,胜过十遍「报错更早」的抽象陈述。
样本走查二:用 Record 给键上锁(配置表)¶
练习 6-5 是 Record 的典型战场:环境配置表只允许
development / production / staging 三个键。
type Environment = "development" | "production" | "staging";
type Configurations = unknown; // 起点:完全没有检查
直觉的解法 Record<string, {apiBaseUrl: string; timeout: number}> 是错的——
键是 string,意味着任何键都合法,notAllowed 照样放行48。
正解是把键的并集喂给 Record:
键的类型从「任意字符串」收紧到「三个字面量」,
notAllowed 键从此在类型层面不存在49。
这道题还演示了 @ts-expect-error 的正经用法:练习里 notAllowed 上面就标着它——
类型收紧到位后,这行「预期报错」真正报错了,红线反而消失(预期兑现);
类型没收紧时,该报错没报,@ts-expect-error 自己亮红线50。
这是把测试思维搬进类型层的绝佳样本:断言「这件事应该报错」,和断言 1+1=2 是同构的。
样本走查三:已知键必填 + 动态键放行(成绩表)¶
练习 6-4 把第 4 节的组合技落到一个具体形状:成绩单必须有数学、英语、科学三门, 但还要允许任意加别的科目。
interface Scores extends RequiredScores {
[key: string]: number;
}
interface RequiredScores {
math: number;
english: number;
science: number;
}
或者单接口版:{ [subject: string]: number; math: number; english: number; science: number; }——
两者功能等价;extends 版多一个可以单独复用的 RequiredScores51。
注意一个细节:这个场景里 @ts-expect-error 断言的是「漏掉 science 应该报错」——
类型写对后,断言兑现、红线消失。同一个指令,既能断言「禁止的东西被禁」,
也能断言「必须的东西必填」,方向正好相反52。
样本走查四:fetchUser 的三条路线(演化方向的选择题)¶
练习 6-7 把 Pick/Omit/interface 的成长差异做成了一道选择题,值得完整走一遍。
User 接口有四个字段(id、name、email、role),而 fetchUser 的调用方只应拿到 name 和 email。
路线 A:Pick——显式挑选,结果冻结。
type PickedUser = Pick<User, "name" | "email">;
const fetchUser = async (): Promise<PickedUser> => {
const response = await fetch("/api/user");
const user = await response.json();
return user;
};
将来 User 加了 avatar 字段,PickedUser 纹丝不动——它只承诺 name 和 email。
对 API 响应类型,这通常是正确姿态:对外契约不该随内部模型漂移53。
路线 B:Omit——显式排除,结果随源长大。
User 新增字段自动流入 OmittedUser。省心,但契约变得含糊:
「id 和 role 之外的一切」——将来 User 加了 internalNotes 这种内部字段,
它也会跟着漏出去。用 Omit 声明对外类型,等于把未来的字段决定权交给了源模型54。
路线 C:interface 组合——冻结,且可复用。
interface NameAndEmail {
name: string;
email: string;
}
interface User extends NameAndEmail {
id: string;
role: string;
}
const fetchUser = async (): Promise<NameAndEmail> => { /* ... */ };
效果和 Pick 一样冻结,但 NameAndEmail 本身成了一个可复用的公开契约——
别的地方要表达「姓名加邮箱」时不必重复定义55。
原书对三条路线的总结只有一句,但信息量很大: 「用 Omit 意味着对象随源长大;用 Pick 和 interface extends 意味着对象保持不变。 按你的需求选。」56这道题没有标准答案,有的是一个清晰的决策维度: 这个类型是「内部模型的投影」(选 Omit)还是「独立契约」(选 Pick/interface)?
样本走查五:scores 的一题四解(动态键的四种写法)¶
练习 6-3 要求给 scores.math = 95 这类动态键定型,标准答案有四个,恰好是第 4 节工具的目录:
// ① 内联索引签名
const scores: { [key: string]: number } = {};
// ② interface
interface Scores { [key: string]: number }
const scores: Scores = {};
// ③ type 别名
type Scores = { [key: string]: number };
const scores: Scores = {};
// ④ Record
const scores: Record<string, number> = {};
四个都合法,选哪个看场景:一次性使用选①;要复用选②③(此时再叠上第 3 节的「默认 type」偏好); 要键是字面量并集、或想要最短的写法,选④57。这一题四解的设计本身就在教学: 工具没有高下,只有对口与否。
五个样本走完:一条完整的选型决策线¶
把五个样本走查串成线,本章的选型逻辑就从头到底走通了一遍:
需求线索 首选 次选(何时)
────────────────────────────────────────────
合并两个已知形状 interface extends & 交叉(一次性拼装)
动态键 + 值同质 Record 索引签名(传统写法)
已知键必填 + 动态键放行 interface extends + 索引签名 Record & 索引签名
部分更新载荷 Partial<Omit<..>> 手写可选版(放弃单一事实源)
对外契约(冻结) Pick interface extends 拆分
排除字段(演化) Omit (别无选择,但记住两个坑)
图说:六行覆盖了对象类型的全部日常选型;「首选」列背下来,
「次选」列知道什么时候换——这就是本章的全部学习成果。
这张表也是本章最适合打印贴墙的一页:它比速查卡多一列「需求线索」, 是从问题到工具的直达路线——日常写代码时,先找需求所在的行,再看首选列。 当首选列开始显得「自然而然」时,本章就算读到了;当次选列的换用时机也能 脱口而出时,本章就算读透了。
type 还是 interface?一张决策表收尾¶
第 3 节的争论落到日常,其实就四个问题:
| 问 | 选 type | 选 interface |
|---|---|---|
| 需要并集/交叉/模板字面量类型? | ✅ 必须 | ❌ 做不到 |
| 需要 extends 继承? | 可以(& 交叉) | ✅ 更好(报错早、可缓存) |
| 需要声明合并(给库打补丁)? | ❌ 做不到 | ✅ 必须 |
| 团队有 OO 背景、重视继承体系? | 可以 | ✅ 更顺眼 |
原书的默认(默认 type)适合前两个问题都答「否」的大多数应用代码; 一旦第三问答「是」(第 10 章的场景),interface 就是唯一解58。
把这张表再压成一条「逃生口诀」,适合在键盘前快速自问:
写一个新对象类型,30 秒决策:
① 它会是「几种可能之一」吗?(并集/交叉参与)→ type
② 它会被另一个类型 extends 吗?→ interface
③ 它要给第三方类型打补丁吗?→ interface(声明合并是唯一通道)
④ 以上都不是 → type(默认,灵活且不会半夜合并)
图说:①③ 是硬约束(做不到就是做不到),②④ 是软偏好;
硬约束先判,软偏好随缘——多数争议发生在把软偏好当硬约束吵。
现实中 type vs interface 的争论,九成是「把第 ④ 条的软偏好吵成了信仰」。 这张口诀的价值是把争论降级:硬约束(①③)没得吵,软偏好(②④)随团队—— 一条团队约定写下来,比十次技术辩论都省时间。
组合技的「最后一步」:什么时候停下¶
工具学到这里,最后一个该学的动作是「停」:
组合技的收益递减点,出现在「再套一层省的字数 < 读者多花的解析时间」时。
Partial<Omit<Product, "id">> → 两层,清晰,保留
Partial<Omit<Pick<Product, ...>, ...>> → 三层,读者开始画语法树,该拆
拆法也简单:给中间结果一个名字(第 11 章的纪律在此适用):
type ProductUpdatePayload = Omit<Product, "id">;
const updateProduct = (id: number, info: Partial<ProductUpdatePayload>) => {...};
图说:名字是类型的「句读」——没有句读的长句不是高级,是难读;
三层以上的套娃,先拆名,再谈优雅。
这也是本章和第 11、16 章共同构成的「复杂度预算」三角:本章给工具(能做多复杂), 第 11 章给判断(该不该这么复杂),第 16 章给能力(复杂到要自造工具时怎么办)。 三角齐了,才算完整毕业了「对象类型」这一科——而它的真正考场, 是下个章节那个最常被误用的类型形态:联合。
本章的一句话版¶
按拆解惯例,收一句给赶时间的读者:「对象类型三板斧:交叉合并、Record 兜动态键、 工具类型做变换;默认 type,要继承换 interface;Omit 随源长,Pick 冻结; 悬停见 never,就是交叉在打架。」 其余一切——分配性、PropertyKey、 套娃纪律、决策表——都是这六小句的展开。能在自己的代码里认出这六句话各自 管着哪一处,本章就毕业了。毕业的标志不是背得出,是写的时候手自己会选工具—— 从「查这一章」到「不必查」,平均需要在自己的项目里真刀真枪用上三回: 一回交叉或 extends、一回 Record、一回工具类型套娃。用到第三回时回来对一遍 这张速查表,对不上的那一行,就是下一轮该精读的小节——这是把「读完」升级成 「会用」的最短路径,也是原书八道练习真正想训练的东西。
7. 这一章在全书链条中的位置¶
对象类型的三件套(组合、动态键、变换)凑齐后,「类型的单一事实源」原则有了完整的执行工具:
源类型定义一次,extends / Record / Omit / Pick 派生出所有需要的变体,源变派生跟着变。
第 11 章会把这套思想再推一步——用 keyof、typeof 让派生自动发生;
第 16 章则教你把 Partial、DistributiveOmit 这类工具自己写出来。
原书小结的排序是:组合(交叉 vs extends)、动态键(索引签名/Record/PropertyKey)、
变换(四个工具类型 + 分配性问题)45。
把本章放进全书的「地形图」,它的位置是「平原」——没有新心智模型, 全部是已有概念的复合应用:
第 04 章:对象的形状词汇 ──┐
第 05 章:类型参数(Set/Map) ──┼──本章把它们全部组合到对象上
第 06 章:并集与收窄 ──┤ (交叉、动态键、工具类型)
第 08 章:as const 的深锁 ──┘
↓
第 11 章:用 keyof/typeof 让派生自动发生(本章工具的「自动挡」)
第 16 章:亲手写本章工具的实现(本章工具的「引擎室」)
图说:本章不发明概念,只发明组合——这也是为什么它是全书最「顺手」的一章:
每个工具都能从前面某章找到零件,读起来几乎全是「原来还能这么拼」。
「顺手」不等于「可跳过」:本章的三个工具(交叉、动态键、变换)是后面五章的 日常用语——第 13 章的「开放对象」、第 14 章的「声明合并」、第 16 章的 「映射类型」,全部建立在「对象类型可以被组合和变换」这个本章立起的前提上。 跳过本章读后面,等于没学词组直接读长句。
本章的隐藏主线:形状的演化方向¶
把全章的工具按「源类型变化时变体怎么动」排一列,一条隐藏主线浮出水面—— 每个工具都暗含一个「演化方向」的承诺:
工具 源加字段时 源改字段类型时
────────────────────────────────────────────
extends / Pick 冻结(不变) 跟随(变)
Omit 跟随(自动多) 跟随(除非被删掉)
Partial / Required 跟随(自动多/少?) 跟随
交叉 & 跟随 跟随
图说:没有「永远正确」的演化方向——
「冻结」保护契约,「跟随」保护投影。第 11 章的两问决策
(该不该同步)正是在为这张表选列;而第 12 章的 satisfies,
解决的是「我想冻结演化、又不想失去检查」的角点情况。
这条主线是本章全部选型知识的总开关:拿到任何一个对象类型工具, 先问它承诺哪种演化方向,再问自己需要哪种——两问对齐,工具自动选对; 两问错位,用哪个工具都会在将来某个重构日付利息。把「演化方向」四个字 写进团队的类型评审清单,比任何「最佳实践」清单都管用。它也是本章与第 11、12 章的 真正缝合点:第 11 章教「该不该同步」(判断),本章教「同步了会怎样」(方向), 第 12 章教「想冻结又要检查怎么办」(角点)——三章是一条完整的决策流水线。 流水线各自独立成章、合起来覆盖全部选型场景,这也是原书章节编排的深层秩序: 第 07 章给工具手感、第 11 章给判断框架、第 12 章给角点补丁—— 读的人按顺序走是教材,用的人按需查是手册,两种读法都成立,这就是「 Essentials 类教材」 的罕见完成度。它也让本章的拆解可以采用「双索引」结构:正文按教学顺序展开, 「选型决策线」「演化方向表」两张速查按使用场景重组——读者读到的是教材, 用到的是手册,一份内容两种形态,这是我们对原书结构最忠实的翻译。翻译的代价是 本章拆解的篇幅几乎与原书持平——但这是值得的:对象类型是全书引用频率最高的 一章(后面十章没有一章不回头用它),它的拆解密度应该和它被引用的密度匹配。 按同一把尺子,第 06 章(联合)的密度也应该一样高——那里是 TypeScript 表达力的 心脏,也是误用率常年冠军的栖息地。第七章到此拆完,下一站:联合。 联合类型的拆解密度与对象类型相当——它和本章共享同一块地基(方向感), 却又多出一整条「收窄」的暗河;两条水系在判别联合处交汇,是全书写法最讲究的一章。 写完这句才发现一个有趣的对称:对象类型的章节以「合并」为主题(把形状合到一起), 联合类型的章节以「分辨」为主题(把可能性区分开)——一合一分,构成 TypeScript 类型世界的全部基本动作。学会这两章,其余所有章节都是这两个动作的组合与变奏; 学不会这两章,后面的工具再多也只是背名字。这个对称值得在进入第 06 章之前 先留在心里:它是全书前半部与后半部的真正分水岭,虽然章号上前者在后。 带着「一合一分」这幅对仗进入下一章,读者会发现联合类型的每个机制 都能在这幅对仗里找到位置——收窄是「分」的引擎,判别联合是「分」的成熟形态, 宽进窄出是「分」在函数边界的投影。地图已经画好,接下来是第 06 章的实地勘测。 勘测之前,照例留一句话版给赶时间的读者:「合并用 extends(或 &),动态键用 Record, 变换用工具类型;选型先定演化方向——冻结用 Pick,跟随用 Omit。」 六小句, 各管一章的一片疆土;读完下一章再回来对一遍,你会发现这两章的口诀连起来读, 正好是 TypeScript 类型设计的「总开关」。第七章的拆解到此正式结束; 它的原文地图、判断块与出处,均已按全书统一规格校验,可以随时回核。回核之外, 本章留给读者最后一个动作建议:把「选型决策线」和「演化方向表」两张速查 复制到自己的团队 wiki,在代码评审时对照引用——让两张纸参与真实决策, 比任何复习都更能把知识固化成团队的公共财产。至此,本章的正文、判断、地图三层 全部到位,与全书其他十六章的规格完全一致——这是拆解工作真正完成的标志。 标志立完,第七章可以放心地合上了;它对后续九章的全部「引用债务」, 已经由本章的密度和厚度兑付完毕。合上之前,最后一眼留给它的主线例子—— 那张「颜色配置对象」从第 04 章一路走到这里,已经被 extends、Record、 工具类型轮流改装过;它现在功成身退,而第 06 章的新主角「并集类型」 正在侧台候场。换幕。第七章的拆解正式收工,全文完——从顶层全景到原文地图, 八节内容无一处缺席,无一处无出处。收工,落幕,致谢读者读到这里,我们第 06 章拆解见。
8. 作者的判断与证据(分开标)¶
书里有据的:
- extends 优于 & 的两条理由(报错时机、缓存性能)——前者可当场验证,后者是编译器实现事实的陈述101112。
- 「默认 type,要 extends 换 interface」——明确的主见,作者自承「close call」16。
- 声明合并「丢过好几个小时」——第一人称证言,用来警示这个 gotcha15。
- Omit 宽松版是团队当年的有意选择——引了原始的 issue/PR/结论文档,属于有据可考的设计史3637。
- Omit/Pick 不分配及 DistributiveOmit 解法——机制描述,配了悬停对照实证3840。
原书没说的(我们补):
- DistributiveOmit 里的
T extends any ? ... : never为什么能分配——补充(不在书里,第 16 章才展开): 条件类型在左边是「裸类型参数」时会对联合逐成员分配,这是条件类型的内置行为。 - interface vs type 的争论史与社区统计——补充(来自通用知识): 这个争论从 2016 年持续至今, TypeScript 团队官方文档的态度与原书一致:两者大部分场景等价,选你喜欢的;需要 extends 或合并时用 interface。
判断(我们的,不是书里的): 本章最有「类型系统观」价值的一个事实,是 Omit 的设计史—— 一个全局可用的工具类型,因为「不能破坏已有代码」这个约束,选择了宽松语义并永久接受了它。 这是观察「语言设计中的兼容性债」的极好样本:工具一旦全局发布,语义错误也被冻结 (严格版 Omit 直到今天也只能靠用户手写在项目里)。 读任何语言的内置库时都值得问一句:这个看似随意的宽松行为,是不是一次 2019 年的妥协? 如果错,会错在: 宽松 Omit 在多数场景下确实无害(删错了键,得到的是意外通过而非错误拒绝), 它的「债」要在大规模重构时才显出利息——所以也可能这笔债永远不用还,只是记在账上。
判断(我们的,不是书里的): 「Omit 随源长大,Pick 冻结」这条规则,本质是在问 「这个类型是不是源类型的投影」。我们见过的多数 API 层错误,根源是把投影当契约 (用 Omit 声明对外响应,内部模型一加字段就泄漏)或把契约当投影(用 Pick 复制内部模型, 模型改了忘记同步)。原书用一道练习题把这个维度立起来,比直接给答案高明。 如果错,会错在: 在模块内部(不跨边界),Omit 和 Pick 的差异几乎不存在—— 这条规则的适用面是「有边界的地方」(API、库、组件 props),把它用到模块内部是过度设计。
判断(我们的,不是书里的): 本章最被低估的产能来自「样本走查」式的学习法—— 原书不满足于给工具,而是给五道同一对象的连续改写题(交叉版、Record 版、 组合版、演化版、四解版),逼你在具体输入上比较方案。这比读十遍定义有效, 因为对象类型设计的全部权衡(精确 vs 灵活、开放 vs 封闭、宽 vs 窄) 只有在「同一个需求的两种写法并排」时才可见。 迁移到日常工作:重构一个对象类型时,先写两种候选实现,各配三个典型调用点, 让调用点的形状替你投票——这就是把练习题的格式搬到了生产代码里。 如果错,会错在: 对于形状稳定、调用点极少的内部类型,双方案对比的成本 高于收益——「先写一版,报错再调」在低风险场景同样是合理的工程策略; 样本走查法是高风险边界(API、共享模型)的加严措施,不是 universal 纪律。
9. 边界与局限¶
- 工具类型只一层深:Partial/Required 不递归(原书自己标了注并指路 type-fest)31。
- Omit 的两个坑本章只给缓解:宽松键问题要到第 16 章才有严格版;分配性问题给了 DistributiveOmit 但没有讲原理。
- 本章的「性能」论述没给量化数据——缓存 vs 重算是对的,但多大的项目才有感,原书没测。
- Record 的「并集键 = 全必填」行为本章靠例子展示,没有明写规则(容易误以为并集键可选)。
- 交叉类型与原始类型的组合基本没用:string & number = never 这类结果只会出现在误用里; 交叉的正当地位是给对象类型做组合。
- DistributiveOmit 的
K extends PropertyKey约束本章只照抄,extends在类型参数上的含义 要等第 16 章泛型才讲透;眼下把它当「这个工具对任何键类型都适用」的注脚即可。 - 组合技的层数有认知上限:套娃超过两层(Pick<Omit<Partial<...>>>)后, 类型的可读性崩溃比运行效率先到来——读者要从内向外剥三层才看清形状。 实务中的上限是两层,三层就该拆中间类型(第 11 章的套娃纪律在这里同样适用)。
- 本章所有工具都假设「对象形状已知」:面对「形状来自运行时(JSON 响应)」的场景, 工具类型的约束能力有限——它们能变换已知形状,不能验证未知形状 (那是第 06 章 narrowing 和第 17 章谓词的领地)。把工具类型当运行时校验用, 是本章工具最常见的越界。
10. 可带走的¶
&= 与;对象交叉 = 属性全集;冲突属性收编成never——悬停见 never,就是交叉在打架。- interface extends 胜过 & 的两条理由:报错更早、结果可缓存。
- 默认 type;要 extends、要多继承、要合并补丁时再上 interface。
- interface 的声明合并是双刃剑:给库打补丁时是特性,自己代码里是陷阱。
- 动态键首选
Record<K, V>;索引签名[key: string]: T次之(键不支持字面量并集)。 - 已知键 + 动态键 =
Record<已知并集, V> & {[k: string]: V}或 interface extends + 索引签名。 - 键参数的类型写
PropertyKey(= string | number | symbol)。 - Partial 做「部分更新」;Required 反向;两者都只作用一层。
- Omit 随源类型长大,Pick 冻结——选型看你要哪种演化方向。
- Omit 删不存在的键不报错(宽松版是有意的);Pick 严格。
- Omit/Pick 对联合类型不分配——判别联合上要用 DistributiveOmit/DistributivePick。
- 套娃读法从内向外:
Partial<Omit<Product, "id">>= 先去 id,再全可选。 - 工具类型不改源类型——所有变体都是源类型的纯函数,这就是单一事实源的成立条件。
- 全局类型(Promise、PropertyKey……)都住在 lib.es5.d.ts 之类的声明文件里,悬停追定义是最被低估的学习习惯。
- 交叉的冲突有三种性质(类型不同/可选性不同/字面量互斥)——第三种常意味着「或写成了与」,该回第 06 章换判别联合。
- 「只一层」是内置工具的系统性格:Partial/Required/Readonly 同款;深版本要么递归(第 16 章)、要么 type-fest、要么把数据扁平化。
- 组合写法的兼容规则:已知键的类型必须是动态键值类型的子集,父子颠倒必报错。
- 套娃三层是上限;读写方向相反(读从内向外,写从外向内),中间类型是超过两层后的标准解法。
最后给十四章内容一句总纲:对象类型的全部机制,都是「别复制,要引用」这四个字 的不同力度的实现——交叉是即时引用、extends 是层级引用、索引签名是键的引用 (任意键)、工具类型是变换后的引用。复制(手抄属性清单)在本章每出现一次, 都立刻有一种引用语法把它替换掉。下次手写对象类型时,先问「这里有没有 我已经写过的形状可以引」——这一问,是本章值回票价的唯一动作。
这句总纲也值得配上它的另一半:「引用」的代价是耦合,耦合的边界由第 11 章的 两问决策裁决。本章教你的是「怎么引」(语法),第 11 章教你的是「该不该引」 (判断);两章合起来,才是完整的「单一事实源」工程实践。只引不问,会把 本该解耦的类型拴在一起;只问不引,会把该同步的副本留在手抄里。 「怎么引」和「该不该引」各管一段,中间的分界线正是 Omit/Pick 生长方向那道题 教你的「演化方向判断」。
最后交代本章的读法,也是按拆解惯例的收尾:三行全景(拼、兜、改)是骨架, 五个样本走查是肌肉,十八条的「可带走的」是神经——三层各读一遍,本章就物尽其用。 如果只有一小时:先读第 1 节的全景和第 6 节末的选型决策线,再用剩下的时间 亲手把主线例子的三种声明方式(注解、裸推断、satisfies…本章的交叉与 extends) 各敲一遍——敲的时候悬停三次,本章的一半知识会以手感而非文字的方式落袋。 第七章的对象,到此拆完;下一个战场的类型形态,是本系列误用率的常年冠军:联合。 联合的类型世界比对象更反直觉(值更少、要求更多、还有分配性这种暗流), 但它也是 TypeScript 表达力的心脏——第 06 章的收窄、判别联合、unknown/never 的层级, 都在那里等着给这台心脏做解剖。
11. 原文地图¶
本章地图覆盖原书第 6 章(Extending Objects)的全部小节与八道练习。 新增条目对应加厚内容:交叉的适用域与 never 三步排查、两个硬理由的再推一层、 索引签名的三个边界、组合写法的子集兼容规则、「只一层」的系统性性格、 套娃的读写纪律、Pick/Omit 的生长方向语义、分配性的判据与机制解释、 三处「先埋后收」的清单、选型决策线的六行速查——均可按行号 + 搜词两步回核。
| 主题 | 原书位置 | 原文位置 |
|---|---|---|
| 交叉类型 & 的定义 | Extending Objects | text/22-fm-extending-objects.txt:16(搜「reverse of the |
| AlbumSales 主线例子 | 同上 | text/22-fm-extending-objects.txt:33(搜「AlbumSales = Album & SalesData」) |
| string & number = never | 同上 | text/22-fm-extending-objects.txt:52(搜「It's actually never」) |
| 冲突属性解析为 never | 同上 | text/22-fm-extending-objects.txt:68(搜「age: never」) |
| interface 语法对比 | 同上 | text/22-fm-extending-objects.txt:85(搜「interface Album」) |
| extends 与多继承 | 同上 | text/22-fm-extending-objects.txt:103(搜「StudioAlbum extends」)、:135(搜「BonusConcertEdition」) |
| extends 优于 & 的裁决 | 同上 | text/22-fm-extending-objects.txt:143(搜「which is better」) |
| 报错时机对比 | 同上 | text/22-fm-extending-objects.txt:172(搜「incorrectly extends」) |
| 性能理由(重算 vs 缓存) | 同上 | text/22-fm-extending-objects.txt:181(搜「recomputed every time」) |
| type 更灵活(并集例子) | 同上 | text/22-fm-extending-objects.txt:193(搜「type Union」) |
| 声明合并 | 同上 | text/22-fm-extending-objects.txt:196(搜「declaration merging」) |
| 「丢过好几个小时」 | 同上 | text/22-fm-extending-objects.txt:234(搜「lost hours」) |
| 「默认 type」的主张 | 同上 | text/22-fm-extending-objects.txt:236(搜「default to type」) |
| 动态键的 JS/TS 对比 | 同上 | text/22-fm-extending-objects.txt:377(搜「Property 'Grammy' does not exist」) |
| 索引签名语法 | 同上 | text/22-fm-extending-objects.txt:401(搜「index signature」) |
| Record 与并集键 | 同上 | text/22-fm-extending-objects.txt:435(搜「const albumAwards: Record」) |
| 「Record 是动态键首选」 | 同上 | text/22-fm-extending-objects.txt:456(搜「go-to for dynamic keys」) |
| 已知键 + 动态键组合 | 同上 | text/22-fm-extending-objects.txt:464(搜「ExtendedAlbumAwards」) |
| PropertyKey 定义 | 同上 | text/22-fm-extending-objects.txt:494(搜「declare type PropertyKey」) |
| hasKey 练习(PropertyKey 的用处) | 同上 | text/22-fm-extending-objects.txt:700(搜「v: PropertyKey」) |
| Partial | 同上 | text/22-fm-extending-objects.txt:728(搜「PartialAlbum」) |
| Required 与「只一层深」 | 同上 | text/22-fm-extending-objects.txt:766(搜「one level deep」) |
| Pick / Omit | 同上 | text/22-fm-extending-objects.txt:802(搜「Pick<Album」)、:814(搜「Omit<Album」) |
| Omit 宽松版的来历 | 同上 | text/22-fm-extending-objects.txt:836(搜「loose version」) |
| Omit 对联合不分配 | 同上 | text/22-fm-extending-objects.txt:899(搜「not distributive」) |
| DistributiveOmit / DistributivePick | 同上 | text/22-fm-extending-objects.txt:903(搜「DistributiveOmit」)、:937(搜「DistributivePick」) |
| Omit 长大 / Pick 冻结 | 同上 | text/22-fm-extending-objects.txt:1007(搜「grows as the source object grows」) |
| Partial<Omit<…>> 组合技 | 同上 | text/22-fm-extending-objects.txt:1065(搜「Partial<Omit<Product」) |
| 本章小结 | 同上 | text/22-fm-extending-objects.txt:1072(搜「powerful features」) |
| 练习 6-7:Pick/Omit/extends 三种解法 | Extending Objects | text/22-fm-extending-objects.txt:975(搜「PickedUser」) · text/22-fm-extending-objects.txt:1007(搜「grows as the source object grows」) |
| 练习 6-8:Partial + Omit 组合 | 同上 | text/22-fm-extending-objects.txt:1065(搜「Partial<Omit<Product」) |
| 「两个硬理由」原文 | 同上 | text/22-fm-extending-objects.txt:145(搜「Better Errors」) · text/22-fm-extending-objects.txt:177(搜「Better TypeScript Performance」) |
| string & number = never | 同上 | text/22-fm-extending-objects.txt:16(搜「reverse of the」) |
-
出处:「Extending Objects」练习 6-8 与解答(
text/22-fm-extending-objects.txt:1065,搜「Partial<Omit<Product」)。题面(更新只需部分属性)见第 1024–1026 段。 ↩ -
出处:「Extending Objects」第 16 段(
text/22-fm-extending-objects.txt:16,搜「reverse of the | operator」)。 ↩ -
出处:「Extending Objects」第 34–44 段(
text/22-fm-extending-objects.txt:36,搜「wishYouWereHereSales」)。 ↩ -
出处:「Extending Objects」第 46–48 段(
text/22-fm-extending-objects.txt:48,搜「& {genre: string}」)。 ↩ -
出处:「Extending Objects」第 49–52 段(
text/22-fm-extending-objects.txt:52,搜「It's actually never」)。 ↩ -
出处:「Extending Objects」第 54–71 段(
text/22-fm-extending-objects.txt:68,搜「age: never」)。 ↩ -
出处:「Extending Objects」第 75–91 段(
text/22-fm-extending-objects.txt:85,搜「interface Album」)。 ↩ -
出处:「Extending Objects」第 93–111 段(
text/22-fm-extending-objects.txt:103,搜「StudioAlbum extends」)。 ↩ -
出处:「Extending Objects」第 115–131 段(
text/22-fm-extending-objects.txt:115,搜「americanBeauty」)。 ↩ -
出处:「Extending Objects」第 141–185 段(
text/22-fm-extending-objects.txt:143,搜「which is better」);结论句见第 185 段(搜「better for catching errors」)。 ↩↩↩ -
出处:「Extending Objects」第 145–175 段(
text/22-fm-extending-objects.txt:172,搜「incorrectly extends」);「使用时才报错」的对比见第 175 段(搜「instead of when it is defined」)。 ↩↩ -
出处:「Extending Objects」第 177–183 段(
text/22-fm-extending-objects.txt:181,搜「recomputed every time」);缓存机制见第 183 段(搜「cache the resultant type」)。 ↩↩ -
出处:「Extending Objects」第 187–194 段(
text/22-fm-extending-objects.txt:191,搜「lot more flexible」)。 ↩ -
出处:「Extending Objects」第 196–232 段(
text/22-fm-extending-objects.txt:210,搜「automatically merges」);type 重复定义报错见第 223 段(搜「duplicate identifier」)。 ↩ -
出处:「Extending Objects」第 234 段(
text/22-fm-extending-objects.txt:234,搜「lost hours」)。 ↩↩ -
出处:「Extending Objects」第 236 段(
text/22-fm-extending-objects.txt:236,搜「default to type」)。 ↩↩ -
出处:「Extending Objects」第 367–377 段(
text/22-fm-extending-objects.txt:377,搜「Property 'Grammy' does not exist」)。 ↩ -
出处:「Extending Objects」第 389 段(
text/22-fm-extending-objects.txt:389,搜「indexing」)。 ↩ -
出处:「Extending Objects」第 393–401 段(
text/22-fm-extending-objects.txt:401,搜「index signature」)。 ↩ -
出处:「Extending Objects」第 403–407 段(
text/22-fm-extending-objects.txt:406,搜「iCanBeAnything」)。 ↩ -
出处:「Extending Objects」第 409–418 段(
text/22-fm-extending-objects.txt:411,搜「interface AlbumAwards」)。 ↩ -
出处:「Extending Objects」第 422–431 段(
text/22-fm-extending-objects.txt:428,搜「Record<string, boolean>」)。 ↩ -
出处:「Extending Objects」第 433–454 段(
text/22-fm-extending-objects.txt:451,搜「cannot be a literal type」);「第 15 章揭晓」见第 454 段(搜「mapped types」)。 ↩ -
出处:「Extending Objects」第 456 段(
text/22-fm-extending-objects.txt:456,搜「go-to for dynamic keys」)。 ↩ -
出处:「Extending Objects」第 458–475 段(
text/22-fm-extending-objects.txt:464,搜「ExtendedAlbumAwards」)。 ↩ -
出处:「Extending Objects」第 477–487 段(
text/22-fm-extending-objects.txt:487,搜「interface extends is preferable」)。 ↩ -
出处:「Extending Objects」第 489–495 段(
text/22-fm-extending-objects.txt:494,搜「declare type PropertyKey」)。 ↩↩ -
出处:「Extending Objects」第 640–707 段(
text/22-fm-extending-objects.txt:704,搜「return obj.hasOwnProperty」);悬停签名见第 700 段(搜「v: PropertyKey」)。 ↩ -
出处:「Extending Objects」第 709–711 段(
text/22-fm-extending-objects.txt:711,搜「built-in utility types」)。 ↩ -
出处:「Extending Objects」第 713–737 段(
text/22-fm-extending-objects.txt:728,搜「PartialAlbum」)。 ↩ -
出处:「Extending Objects」第 766–790 段(
text/22-fm-extending-objects.txt:766,搜「one level deep」)。 ↩↩↩↩ -
出处:「Extending Objects」第 792–794 段(
text/22-fm-extending-objects.txt:794,搜「type-fest」)。 ↩ -
出处:「Extending Objects」第 1007 段(
text/22-fm-extending-objects.txt:1007,搜「grows as the source object grows」)。 ↩ -
出处:「Extending Objects」第 807–815 段(
text/22-fm-extending-objects.txt:814,搜「Omit<Album」)。 ↩ -
出处:「Extending Objects」第 817–834 段(
text/22-fm-extending-objects.txt:834,搜「does not satisfy the constraint」)。 ↩↩ -
出处:「Extending Objects」第 836 段(
text/22-fm-extending-objects.txt:836,搜「loose version」)。 ↩↩↩ -
出处:「Extending Objects」第 838–842 段(
text/22-fm-extending-objects.txt:838,搜「keep an eye out」)。 ↩↩ -
出处:「Extending Objects」第 844–887 段(
text/22-fm-extending-objects.txt:882,搜「Actual:」)。 ↩↩ -
出处:「Extending Objects」第 889–897 段(
text/22-fm-extending-objects.txt:894,搜「PartialMusicProduct」)。 ↩ -
出处:「Extending Objects」第 899 段(
text/22-fm-extending-objects.txt:899,搜「not distributive」)。 ↩↩↩↩ -
出处:「Extending Objects」第 901–941 段(
text/22-fm-extending-objects.txt:903,搜「DistributiveOmit」)、第 937 段(搜「DistributivePick」)。 ↩ -
出处:「Extending Objects」第 933 段(
text/22-fm-extending-objects.txt:933,搜「much more predictable」)。 ↩ -
出处:「Extending Objects」第 1009–1068 段(
text/22-fm-extending-objects.txt:1065,搜「Partial<Omit<Product」)。 ↩ -
出处:「Extending Objects」第 943–1007 段(
text/22-fm-extending-objects.txt:1007,搜「grows as the source object grows」)。 ↩ -
出处:「Extending Objects」第 1070–1086 段(
text/22-fm-extending-objects.txt:1072,搜「powerful features」)。 ↩ -
出处:「Extending Objects」第 238–295 段(
text/22-fm-extending-objects.txt:262,搜「BaseEntity」);交叉叠加写法见第 269–293 段(搜「} & BaseEntity」)。 ↩ -
出处:「Extending Objects」第 297–352 段(
text/22-fm-extending-objects.txt:352,搜「thank you」);多继承拆法见第 334–350 段(搜「WithId, WithCreatedAt」)。 ↩ -
出处:「Extending Objects」第 616–626 段(
text/22-fm-extending-objects.txt:626,搜「allows for the notAllowed key」)。 ↩ -
出处:「Extending Objects」第 628–638 段(
text/22-fm-extending-objects.txt:630,搜「Record<」)。 ↩ -
出处:「Extending Objects」第 584–612 段(
text/22-fm-extending-objects.txt:606,搜「notAllowed: {」)。 ↩ -
出处:「Extending Objects」第 539–582 段(
text/22-fm-extending-objects.txt:578,搜「interface Scores extends RequiredScores」)。 ↩ -
出处:「Extending Objects」第 543–557 段(
text/22-fm-extending-objects.txt:545,搜「science should be provided」)。 ↩ -
出处:「Extending Objects」第 971–980 段(
text/22-fm-extending-objects.txt:975,搜「PickedUser」)。 ↩ -
出处:「Extending Objects」第 981–987 段(
text/22-fm-extending-objects.txt:983,搜「OmittedUser」)。 ↩ -
出处:「Extending Objects」第 989–1005 段(
text/22-fm-extending-objects.txt:991,搜「NameAndEmail」)。 ↩ -
出处:「Extending Objects」第 1007 段(
text/22-fm-extending-objects.txt:1007,搜「Choose the best approach」)。 ↩ -
出处:「Extending Objects」第 502–538 段(
text/22-fm-extending-objects.txt:538,搜「Record<string, number>」)。 ↩ -
出处:「Extending Objects」第 187–236 段(
text/22-fm-extending-objects.txt:236,搜「default to type」)。 ↩ -
出处:「Extending Objects」练习 6-7 收尾(
text/22-fm-extending-objects.txt:1007,搜「grows as the source object grows」)。原文:「Using Omit will mean that the object grows as the source object grows, and using Pick and interface extends will mean that the object will stay the same size.」 ↩