Union、Intersection 和 TypeScript 中的 Narrowing
1. 总览
Union 和 Intersection 是 TypeScript 中两个基本工具,用于描述比简单 interface 更灵活的数据。Narrowing 是一种技术,帮助编译器“理解”代码每个分支中值的具体类型,而不是强制你手动进行类型转换(as)。
本文从 union/intersection 的概念开始,经过常见的 narrowing 技术(typeof、instanceof、in、discriminated union),到编写自定义类型守卫。读完这篇文章后,你将知道何时使用 union,何时使用 intersection,以及如何避免“Object is possibly undefined”或代码中充斥 as any 的问题。
2. Union 类型
Union(|)意味着一个值可能属于多种中的任何一种。
type PaymentMethod = "cash" | "card" | "e-wallet";
function describe(method: PaymentMethod): string {
return `支付方式:${method}`;
}
describe("card"); // OK
// describe("paypal"); // 编译错误:不在 union 中
PaymentMethod 只接受 3 个特定的字符串。如果传入其他值,TypeScript 会在编译时立即报告错误,而不是等到运行时。
Union 也适用于对象类型:
interface CashPayment {
type: "cash";
amount: number;
}
interface CardPayment {
type: "card";
amount: number;
cardNumber: string;
}
type Payment = CashPayment | CardPayment;
此时,Payment 类型的变量可能是 CashPayment 或 CardPayment,但如果还没有进行 narrowing,TypeScript 只允许你访问两者共有的字段(这里是 type 和 amount)。
function printAmount(p: Payment) {
console.log(p.amount); // OK,共有字段
// console.log(p.cardNumber); // 错误:在 CashPayment 上不存在
}
注意: Union 不会合并字段。要访问私有字段,你必须先进行 narrowing(见第 4 部分)。
3. Intersection 类型
Intersection(&)意味着一个值必须同时满足所有被组合的类型。
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
}
type Order = Identifiable & Timestamped & {
customerName: string;
total: number;
};
const order: Order = {
id: "ORD-001",
createdAt: new Date(),
customerName: "阮文甲",
total: 150_000,
};
这里的 Order 同时具有 Identifiable、Timestamped 和额外对象字面量的所有字段。缺少任何字段,编译器都会报错。
Intersection 在你想将多个小的 mixin 组合成一个大 type 时很有用,类似于 OOP 中的 composition,但在类型级别上。
type AuditInfo = { updatedBy: string };
type AuditedOrder = Order & AuditInfo;
const audited: AuditedOrder = {
...order,
updatedBy: "admin",
};
3.1. Intersection 与冲突的原始类型
如果 intersection 中的两个类型在原始类型上冲突,结果是 never:
type Impossible = string & number; // 类型为 never,没有值能满足
这种情况通常表示你错误地使用了 & 而不是 |。
4. Narrowing:在代码的每个分支中缩小类型范围
Narrowing 是编译器基于代码中的条件检查(if、switch、typeof…)自动推断更具体的类型的过程。
4.1. 使用 typeof 进行 Narrowing
用于由原始类型组成的 union。
function formatId(id: string | number): string {
if (typeof id === "number") {
return id.toFixed(0); // id 被 narrow 为 number
}
return id.toUpperCase(); // id 被 narrow 为 string
}
在 if 分支中,TypeScript 知道 id 是 number,所以允许调用 .toFixed()。在隐含的 else 分支中,id 仅剩下 string 类型。
4.2. 使用 instanceof 进行 Narrowing
用于由类组成的 union。
class ValidationError extends Error {
constructor(public field: string) {
super(`字段 ${field} 无效`);
}
}
function handleError(err: Error | ValidationError): void {
if (err instanceof ValidationError) {
console.log(`验证字段错误:${err.field}`);
} else {
console.log(`其他错误:${err.message}`);
}
}
err.field 只存在于 ValidationError 上,所以编译器只在已检查 instanceof 的分支中允许访问。
4.3. 使用 in 进行 Narrowing
用于 union 中的类型通过私有字段区分的情况(没有共有的 discriminant 字段)。
interface Dog {
bark: () => void;
}
interface Cat {
meow: () => void;
}
function makeSound(pet: Dog | Cat): void {
if ("bark" in pet) {
pet.bark(); // narrow 为 Dog
} else {
pet.meow(); // narrow 为 Cat
}
}
in 操作符在运行时检查对象上是否存在属性,编译器使用这个结果来 narrow 相应的类型。
4.4. Discriminated Union —— 最安全的方式
这是实际代码中最常见的模式:添加一个共有的字面量字段(通常称为 kind 或 type)来区分 union 中的“分支”。
interface SuccessResult {
status: "success";
data: string;
}
interface ErrorResult {
status: "error";
message: string;
}
type ApiResult = SuccessResult | ErrorResult;
function handleResult(result: ApiResult): void {
switch (result.status) {
case "success":
console.log(`数据:${result.data}`);
break;
case "error":
console.log(`错误:${result.message}`);
break;
}
}
编译器使用 status 字段作为“discriminant”:在 case "success" 中,result 被 narrow 为 SuccessResult;在 case "error" 中,narrow 为 ErrorResult。这是 TypeScript 对所有超过 2 个分支的复杂 union 推荐的方式,因为 switch 还支持穷尽性检查(见 4.6)。
4.5. 使用真值性和可选链进行 Narrowing
interface Customer {
name: string;
phone?: string;
}
function callCustomer(customer: Customer): void {
if (customer.phone) {
// customer.phone 从 string | undefined 被 narrow 为 string
console.log(`拨打号码:${customer.phone}`);
} else {
console.log("客户没有电话号码");
}
}
检查真值(if (customer.phone))排除了 undefined(以及空字符串,但对电话号码不重要)。
4.6. 使用 never 检查穷尽性
当在 union 中添加新分支但忘记处理时,你希望编译器立即报错,而不是运行时才出现 bug。在 default 分支中使用 never 类型:
function assertUnreachable(x: never): never {
throw new Error(`未处理的情况:${JSON.stringify(x)}`);
}
function handleResultSafe(result: ApiResult): string {
switch (result.status) {
case "success":
return result.data;
case "error":
return result.message;
default:
return assertUnreachable(result); // 如果添加了新分支但忘记处理,编译时在此报错
}
}
如果后来有人在 ApiResult 中添加了 PendingResult 但忘记添加相应的 case,default 分支中的 result 将不再是 never,编译器会立即报错 —— 无需等待 bug 在生产环境中出现。
5. 用户定义的类型守卫
当检查逻辑比一个简单表达式更复杂时,你可以编写返回 x is T 类型的函数来重用 narrowing。
interface PaidOrder {
status: "paid";
paidAt: Date;
}
interface PendingOrder {
status: "pending";
}
type OrderState = PaidOrder | PendingOrder;
function isPaid(order: OrderState): order is PaidOrder {
return order.status === "paid";
}
function printReceipt(order: OrderState): void {
if (isPaid(order)) {
console.log(`支付时间:${order.paidAt.toISOString()}`);
} else {
console.log("未支付");
}
}
order is PaidOrder 是一个类型谓词。编译器信任 isPaid 函数的结果来将 order narrow 为 PaidOrder 在 if 分支中,即使检查逻辑在单独的函数中。
6. 使用测试进行验证
Narrowing 是编译时特性,所以“测试”实际上就是运行 tsc 不报错。但你仍应编写对应的运行时行为的测试。
npm install -D vitest typescript
import { describe, it, expect } from "vitest";
function totalAmount(payment: CashPayment | CardPayment): number {
return payment.amount;
}
describe("支付 narrowing", () => {
it("正确计算现金支付的金额", () => {
const cash: CashPayment = { type: "cash", amount: 50_000 };
expect(totalAmount(cash)).toBe(50_000);
});
it("正确计算卡支付的金额", () => {
const card: CardPayment = {
type: "card",
amount: 200_000,
cardNumber: "4111111111111111",
};
expect(totalAmount(card)).toBe(200_000);
});
});
运行 npx vitest run 得到结果:
✓ 支付 narrowing > 正确计算现金支付的金额
✓ 支付 narrowing > 正确计算卡支付的金额
Test Files 1 passed (1)
Tests 2 passed (2)
7. 常见错误和何时不应该使用
- 滥用
as来避免 narrowing:如果你发现自己不断使用类型转换(as SomeType)来“取悦”编译器,很可能 union/type guard 的设计有问题。回到使用 discriminated union。 - Intersection 两个 interface 有相同名称但不同类型的字段:TypeScript 会尝试合并,但如果类型冲突(例如一个是
string,另一个是number),该字段会变成never—— 很容易造成难以理解的错误。在设计 mixin 时始终保持字段名称统一。 - Union 过于宽泛(例如
string | number | boolean | object):使得任何使用该类型的地方都需要 narrowing,代码混乱。如果 union 有超过 4—5 个不相关的分支,重新考虑领域模型 —— 可能应该拆分为多个独立的函数/overload。 - 在 switch 中忘记
default和never:失去了穷尽性检查的优势,在扩展 union 时容易遗漏分支。 - 在具有复杂原型链的对象上使用
in操作符:in会检查继承的属性,对于有深度继承的类可能会产生意外的 narrowing 结果。在这种情况下,instanceof或 discriminant 字段更值得信赖。
注意: Narrowing 仅在 TypeScript 能够通过静态控制流证明时才能正常工作。如果你通过回调函数进行异步操作后将已 narrow 的值分配给另一个变量(setTimeout、promise
.then),被 narrow 的类型可能会被“遗忘” —— TypeScript 不会在所有情况下完全追踪闭包边界。
8. 结论
Union 描述“多种中的一种”,intersection 描述“同时所有类型” —— 这两个工具是在 TypeScript 中构建精确领域模型的基础。Narrowing(typeof、instanceof、in、discriminated union、自定义类型守卫)帮助你安全地使用 union,而不需要手动类型转换。
实用规则:当有 2 个或更多分支时优先选择 discriminated union,始终为 switch 添加穷尽性检查(使用 never),避免 intersection 中字段名相同但类型不同的情况。正确应用,你会在编写代码时就捕获大量逻辑错误,在它们有机会在生产环境中运行之前。