联合类型(union type) A | B 接受满足至少一个成员类型的值; 交叉类型(intersection type) A & B 要求值同时满足两个成员类型。
| 不是把所有属性放进一个对象,& 也不会在运行时合并对象;冲突属性还可能把交叉类型变成无法正常构造的类型。
先收窄联合类型再访问成员专属字段,为对象联合设置稳定的判别字段,并在组合交叉类型前检查重名属性。
是什么,为什么存在
联合类型表示一组可能性。string | number 的值可能是字符串,也可能是数字,调用方可以提供任意一种。接收方在确认具体成员之前,只能执行对每种可能值都安全的操作。
交叉类型表示多项约束必须同时成立。Identified & Timestamped 要求一个值既有身份字段,也有时间字段。它适合组合独立能力和对象片段,不要求这些声明存在继承关系。
两者解决的是静态模型中的不同问题:联合类型描述选择,交叉类型累积要求。函数输入、解析结果、状态机和事件载荷常用联合类型;权限、元数据和可复用能力常用交叉类型。
TypeScript 按 结构类型(structural typing) 判断兼容性。一个值只要拥有所需结构,就可以同时满足多个声明,因此联合类型是包含式的“或”,不是排他的二选一。
不要把联合与交叉机械等同于运行时容器。它们会在编译后被擦除,不创建标签、不复制属性,也不验证外部数据。类型定义必须与真实对象构造和运行时检查相配合。
工作原理
可以把普通类型近似看成可能值的集合。A | B 包含属于 A 或 B 的值,范围通常更宽;A & B 只包含同时属于两者的值,范围通常更窄。这个模型能解释赋值方向和 never,但 any 等特殊类型会破坏简单集合直觉。
若表达式的类型是 A,它可以赋给 A | B,因为联合类型接受更多可能性。反过来通常不成立:一个 A | B 值可能只满足 B。若表达式是 A & B,则它可以分别赋给 A 和 B,因为它已经满足两边的要求。
对象联合不会自动成为“所有字段都可选”的对象。{ email: string } | { phone: string } 表示至少匹配其中一种结构,而且某个运行时对象也可能两种都匹配。要保留字段之间的对应关系,应使用可辨识联合,而不是把每个字段改成可选。
交叉类型也不限于对象。string & "ready" 会缩小为字面量类型 "ready",而 string & number 没有正常值,会得到 never。对象交叉只是在结构类型系统中最常见的用法。
联合上的安全操作
当变量是 string | number 时,编译器必须考虑两种成员。toString() 对两者都存在,因此可直接调用;toUpperCase() 只属于字符串,toFixed() 只属于数字,所以必须先收窄。
“共有成员”指类型检查器能证明安全的成员和调用方式,不只是属性名相同。若两个成员都有同名函数,但参数契约不兼容,知道属性存在仍不表示可以安全调用它。
下面的关系表从使用方角度概括了两种组合:
| 组合 | 值必须满足 | 未收窄时可用信息 | 常见用途 |
|---|---|---|---|
| `A | B` | A、B 至少一个 | 对所有剩余成员都安全的信息 |
A & B | A、B 全部 | 两边兼容成员的合并结果 | 能力组合、附加元数据 |
收窄与可辨识联合
收窄(narrowing)用运行时事实排除联合成员。typeof、instanceof、相等比较、in 检查和自定义类型谓词都能提供不同证据。检查必须真实反映运行时值,类型断言不会产生证据。
对象联合最好共享一个字面量判别字段。status: "paid" 与 status: "failed" 既能在运行时读取,也能让编译器把整个对象连同载荷一起收窄。这种模式叫作 可辨识联合(discriminated union) 。
处理完每个成员后,剩余值的类型是 never,即 底类型(bottom type) 。把默认分支的值传给接受 never 的函数,可以在以后新增成员却忘记处理时产生编译错误。
这项穷尽性保证只覆盖编译器已信任的联合。JSON、消息队列和 JavaScript 调用方可以提供任意值,因此外部数据仍应从 unknown 开始验证,再进入可辨识联合。
交叉、重名属性与运行时值
对象交叉把约束叠加到同一个值上。若 A 要求 id: string,B 要求 createdAt: Date,那么 A & B 同时要求这两个字段。类型别名本身不会生成拥有这些字段的对象。
重名且兼容的属性会继续缩小。例如 { mode: string } & { mode: "safe" } 的 mode 是 "safe"。重名但不兼容的属性会交叉成 never,例如 { id: string } & { id: number }。
属性冲突不会按对象展开顺序选择“最后一个值”。JavaScript 的对象展开确实会覆盖重名属性,但那是运行时求值规则;交叉类型要求一个最终值同时满足两项静态约束。把两者混为一谈,常会诱发不安全的 as A & B。
组合第三方类型前应先查看重名键及其含义。若业务上确实要替换字段,应先用 Omit 移除旧约束再添加新约束;若两个字段含义不同,应直接改名,而不是用断言压过冲突。
示例
下面四个程序依次展示原始类型联合、可辨识联合、对象交叉,以及带公共交叉上下文的联合。每段代码都使用 TypeScript 6.0.3 的 strict 模式完成类型检查,并由本地 tsx 实际执行。
收窄联合类型
标识符可以来自数字数据库键,也可以来自外部字符串。函数先用 typeof 确定成员,再调用该成员专属的方法。
type Identifier = string | number;
function canonicalId(id: Identifier): string {
if (typeof id === "number") {
return `customer:${id.toString().padStart(4, "0")}`;
}
return `customer:${id.trim().toLowerCase()}`;
}
const identifiers: Identifier[] = [42, " ALPHA-7 "];
for (const identifier of identifiers) {
console.log(canonicalId(identifier));
}customer:0042
customer:alpha-7数字分支可以调用 padStart(),因为 toString() 已经产生字符串。字符串分支不需要断言;前一个分支提前返回后,控制流中只剩 string。
这里的联合只表达接受两种输入,并不规定两个表示法是否可能指向同一客户。去重、范围和空字符串属于领域规则,仍要由函数或更外层的验证器明确处理。
用判别字段保持状态与载荷对应
每个支付状态都有自己的必填载荷。switch 检查 status 后可以直接访问对应字段,默认分支则证明当前联合已被穷尽。
type PaymentState =
| { status: "pending"; attempt: number }
| { status: "paid"; receipt: string }
| { status: "failed"; reason: string };
function assertNever(value: never): never {
throw new Error(`Unhandled payment: ${JSON.stringify(value)}`);
}
function summarize(state: PaymentState): string {
switch (state.status) {
case "pending":
return `pending attempt ${state.attempt}`;
case "paid":
return `paid with ${state.receipt}`;
case "failed":
return `failed: ${state.reason}`;
default:
return assertNever(state);
}
}
const states: PaymentState[] = [
{ status: "pending", attempt: 2 },
{ status: "paid", receipt: "rcpt-81" },
{ status: "failed", reason: "card expired" },
];
for (const state of states) {
console.log(summarize(state));
}pending attempt 2
paid with rcpt-81
failed: card expired如果加入 { status: "refunded"; reference: string } 却不增加 case,默认分支中的 state 就不再是 never,TypeScript 会在 assertNever(state) 处报错。
将模型改成 { status: "pending" | "paid" | "failed"; attempt?: number; receipt?: string; reason?: string } 会丢失对应关系。那种类型允许 paid 缺少 receipt,也允许它携带互相矛盾的字段。
用交叉类型组合独立能力
订阅者同时满足身份与偏好两份契约。接收 Subscriber 的函数可以安全使用两侧字段,因为调用方必须提供完整交叉类型。
type HasIdentity = {
id: string;
email: string;
};
type HasPreferences = {
locale: "en" | "zh";
digest: boolean;
};
type Subscriber = HasIdentity & HasPreferences;
function deliveryLabel(subscriber: Subscriber): string {
const schedule = subscriber.digest ? "daily" : "off";
return `${subscriber.id}:${subscriber.locale}:${schedule}`;
}
const subscriber: Subscriber = {
id: "acct-17",
email: "[email protected]",
locale: "zh",
digest: true,
};
console.log(deliveryLabel(subscriber));
console.log(Object.keys(subscriber).sort().join(","));acct-17:zh:daily
digest,email,id,locale第二行输出来自实际对象的键,而不是 Subscriber 类型。删除对象字面量中的 email 会产生编译错误;删除类型别名却不会改变任何运行时对象。
这里两份契约没有重名键,所以组合直接。实际项目中若两侧都声明 locale,应先确认类型与语义一致,再决定保留交叉、重命名还是显式重塑对象。
给联合的每个成员附加公共上下文
导入结果仍由判别字段保留两种载荷,但每个成员还必须携带 batchId。括号先形成联合,外层交叉再把批次上下文应用到所有成员。
type ImportResult = (
| { kind: "accepted"; records: number }
| { kind: "rejected"; errors: string[] }
) & { batchId: string };
function report(result: ImportResult): string {
if (result.kind === "accepted") {
return `${result.batchId}:accepted:${result.records}`;
}
return `${result.batchId}:rejected:${result.errors.join("|")}`;
}
const results: ImportResult[] = [
{ kind: "accepted", records: 18, batchId: "batch-4" },
{
kind: "rejected",
errors: ["missing email", "invalid locale"],
batchId: "batch-5",
},
];
for (const result of results) {
console.log(report(result));
}batch-4:accepted:18
batch-5:rejected:missing email|invalid localebatchId 可在收窄之前读取,因为联合的每条路径都有这项交叉约束。records 与 errors 仍属于各自成员,只有检查 kind 后才能使用。
括号表达了运算分组,也让维护者一眼看出公共上下文的作用范围。公共部分继续扩大时,应给它命名,而不是重复粘贴到每个联合成员。
陷阱
把联合类型当作属性合集
修复方法: 添加稳定的字面量判别字段并先收窄。若两个字段确实总要同时存在,模型需要的是交叉或一份完整对象类型,而不是联合。
用可选字段模拟不同状态
修复方法: 为每种状态定义一个完整成员,让同一个字面量字段区分它们。只在字段对该成员本身确实可缺少时才使用 ?。
以为 & 会合并运行时对象
修复方法: 通过显式对象字面量、经过审查的展开或构造函数创建真实值,然后让编译器检查结果。不要把 as A & B 当作对象合并操作。
忽略交叉中的重名冲突
修复方法: 在组合前审查 keyof 的重叠部分。需要替换字段时使用 Omit<Old, "id"> & NewId 明确写出意图,并为运行时转换添加测试。
用通用默认分支吞掉新成员
修复方法: 让剩余值赋给 never 或传入 assertNever()。外部未知值应在进入联合之前验证,不能借默认分支混入静态状态处理。
在外部边界直接断言联合类型
修复方法: 立即把解析结果放进 unknown,验证非空对象、精确判别值和每个成员的必填字段,再返回领域联合。解析失败和未知判别值都要有明确结果。
类型代数与化简
联合与交叉具有吸收重复项的性质:T | T 和 T & T 都等价于 T。成员顺序通常不改变语义,所以 A | B 与 B | A、A & B 与 B & A 表达相同的值集合,即使编辑器显示形式不同。
若一个成员完全包含另一个成员,编译器可以化简组合。string | "ready" 是 string,因为字面量已经属于字符串;string & "ready" 是 "ready",因为它是同时满足两边的部分。
never 没有正常运行时值,因此 T | never 是 T,T & never 是 never。unknown 可以安全接收任意值,因此 T | unknown 是 unknown,T & unknown 是 T。这些恒等式经常出现在条件类型和工具类型的中间结果中。
下面的表格把这些关系集中起来。它描述的是静态类型化简,不会在 JavaScript 中执行运算。
| 表达式 | 化简结果 | 原因 |
|---|---|---|
T | never | T | 不增加任何可能值 |
T & never | never | 没有值能满足空集合与另一约束 |
T | unknown | unknown | 结果允许任意未知值 |
T & unknown | T | unknown 不再增加具体要求 |
string & "ready" | "ready" | 字面量已经是字符串的子集 |
交叉对联合在值集合上具有分配关系,因此 A & (B | C) 可以理解为 (A & B) | (A & C)。这解释了给每个事件成员附加同一份元数据的常见写法:先写事件联合,再与 { requestId: string } 交叉。
不必为了显示漂亮而强迫编译器展开每个组合。命名中间概念、保留判别字段并检查公开的可赋值关系,比依赖编辑器是否把别名打印成某种形式更稳定。
这些代数规则不能证明运行时数据可信。即使一个复杂类型最终化简正确,从网络获得的值仍没有经过检查;静态集合模型只适用于已经进入类型系统的证据。
对象联合保留字段关系
对象联合的重要价值不是把键放在一起,而是保留相关字段之间的关系。{ format: "json"; payload: string } | { format: "binary"; payload: Uint8Array } 表明 format 决定 payload 的类型。
若把它改成 { format: "json" | "binary"; payload: string | Uint8Array },两个联合会独立变化。类型随后允许 format: "json" 搭配二进制载荷,调用方也无法通过检查 format 收窄 payload。
联合成员可以共享普通字段,例如每种结果都有 requestId: string。共享字段可以直接读取,但成员专属字段仍需收窄。若共享字段很多,可以为公共部分命名,再让每个成员与它交叉。
联合不是排他或。结构足够丰富的对象可能同时满足多个成员,因此不要依赖“它只会属于一个接口”的假设。真正的互斥关系应由不同字面量判别值建立。
设计可用的交叉类型
交叉类型最适合正交、可独立解释的约束。身份、审计信息和分页元数据可以组合,因为每一部分有明确所有者。两个都试图定义核心状态的类型更容易出现重名与语义冲突。
接口继承与交叉类型都能组合对象要求,但失败方式不同。接口继承会在声明处拒绝不兼容的同名属性;交叉类型可以先形成,而冲突属性在使用时表现为 never。对稳定公共对象层次,早失败的接口往往更清楚;对局部类型运算,交叉更灵活。
Omit 后再交叉是一种显式替换字段的方式,但它仍只是静态建模。若旧值中的字段需要从字符串转换成数字,运行时代码必须真正执行转换,并处理失败,而不是只修改类型别名。
交叉类型过长通常暴露了所有权问题。一个值若必须同时满足许多不相关能力,先检查函数是否接收了超出实际需要的契约;缩小参数类型往往比继续添加 & 更易测试。
键与调用签名
对对象联合,keyof (A | B) 只保留在每个成员上都安全存在的键;对对象交叉,keyof (A & B) 通常包含两侧键。这个结果与属性访问规则一致:联合使用方只有共同保证,交叉使用方拥有两侧保证。
同名属性的类型也要考虑读写方向。两个声明都允许读取某个键,不代表向该键写入任意一侧的值都安全。可变对象、可选属性和索引签名会让简单的“键合并”解释失真。
函数类型的交叉常表现得像一组调用签名,库声明会用它描述可接受多种参数形状的可调用值。不过,实现必须真的处理每个签名;断言一个只接受单一输入的函数,不会自动生成重载行为。
公开 API 若需要多个清晰调用形式,函数重载通常比手写函数交叉更易读。交叉更适合由工具类型推导出的结果,前提是正向和负向类型测试都覆盖调用契约。
静态边界与运行时边界
联合与交叉只约束经过 TypeScript 检查的表达式。来自 JSON、DOM 属性、环境变量或无类型包的值必须先保持为 unknown,再通过真实运行时检查获得成员资格。
验证联合时,应先检查容器,再检查判别字段,最后按对应成员验证载荷。只确认 status 存在不够,因为错误类型的字段、数组或原型属性仍可能穿过宽松检查。
验证交叉时,必须验证每一侧承诺的全部条件。把两个不完整的谓词用 && 连接,并不会比各自的实现更可靠;类型谓词签名由编译器信任,测试必须覆盖漏字段和冲突字段。
在测试中同时保留运行时断言与编译期类型用例。运行时测试证明解析和构造行为,类型测试证明允许与拒绝的组合;任何一边都不能替代另一边。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题