我在 TypeScript 里把接口返回定义成成功结果和错误结果的联合类型、本以为这下两种情况的字段我都能随便访问了,结果一访问成功结果才有的 data 字段编译器就报错说这个属性在错误结果上不存在,我对着明明是联合类型为什么不让我访问的报错懵了很久,最后才搞懂联合类型在值上是二选一、在能安全访问的成员上反而只剩两者的交集的深度复盘
这次踩的坑,坑就坑在它违反直觉:我以为"联合(把两个类型用 | 连起来)"意味着"更多、更宽、什么都能访问",结果 TypeScript 告诉我恰恰相反——联合之后,我能安全访问的东西更少了。
故障现场:明明是联合类型,访问字段却报"属性不存在"
我在写一个接口调用的封装,想用 TypeScript 的联合类型把"成功"和"失败"两种返回优雅地表达出来:
interface SuccessResult {
ok: true;
data: string; // 成功时才有的数据
}
interface ErrorResult {
ok: false;
message: string; // 失败时才有的错误信息
}
// 接口返回:要么成功, 要么失败
type ApiResult = SuccessResult | ErrorResult;
function handle(result: ApiResult) {
console.log(result.data); // ❌ 编译报错!
// Property 'data' does not exist on type 'ApiResult'.
// Property 'data' does not exist on type 'ErrorResult'.
}
我当时就愣住了,一连串问号:
- 明明定义了 data,却说不存在:
SuccessResult里明明白白有data字段,可一旦它变成联合类型ApiResult,我访问result.data,编译器就咬死说"Property 'data' does not exist",理由是"ErrorResult 上没有 data"。 - 访问 message 也一样报错:反过来访问
result.message也不行,报"SuccessResult 上没有 message"。 - 只有 ok 能访问:我发现唯独
result.ok能正常访问、不报错——而ok恰好是两个接口都有的字段。 - 和我的直觉完全相反:我一直觉得"联合"就是"合并、加起来",
A | B不应该是"A 的字段加上 B 的字段、都能访问"吗?怎么反而只剩两个都有的那一个能访问了?
"唯独两者共有的 ok 能访问"这个线索太关键了。它分明在暗示:联合类型 A | B 允许我安全访问的,不是 A 和 B 字段的并集,而是它们的交集。这跟"联合 = 更多"的字面直觉正好拧着。我得去搞清楚,联合类型到底是什么含义。
第一件事:搞懂联合类型在"值"上是二选一,在"可访问成员"上是取交集
带着这个反直觉的线索去翻 TypeScript 的类型系统,我才算真正理解了联合类型的含义——关键要分清两个层面:一个值是什么(值层面),和这个值上我能安全访问什么(成员层面),这两件事在联合类型下是反着来的。
先说值层面。type ApiResult = SuccessResult | ErrorResult 的含义是:一个 ApiResult 类型的值,在运行时要么是一个 SuccessResult,要么是一个 ErrorResult——是其中之一,但你事先并不知道是哪一个。"联合"指的是"这个值可能属于的类型的集合变大了"(它可以是 A,也可以是 B)。这一层,确实是"更宽"的。
但关键在成员层面。正因为拿到一个 ApiResult 时,你不知道它具体是 Success 还是 Error,那么 TypeScript 为了保证类型安全,只允许你访问那些"无论它是 A 还是 B 都一定存在"的成员——也就是 A 和 B 的公共成员(交集)。因为:
- 如果你访问
data,可万一这个值实际上是 ErrorResult 呢?ErrorResult 没有data,访问就出错了。所以 TS 不允许。 - 只有
ok这种"两个类型都有"的成员,无论值实际是哪个,访问都绝对安全,所以 TS 才允许。
这下那个反直觉就被彻底理顺了:联合类型 A | B,在"值可能是什么"上是并集(更宽,可以是 A 或 B),但在"你能安全访问哪些成员"上却是交集(更窄,只有公共成员)。"可能性越多"必然导致"能确定的越少"——这恰恰是逻辑自洽的:你不确定它是哪个,自然只能用它们都保证有的东西。我用一个最小例子把这层关系验证得清清楚楚:
type A = { ok: true; data: string };
type B = { ok: false; message: string };
declare const x: A | B; // x 是 A 或 B 之一, 但不知道是哪个
x.ok; // ✅ OK —— ok 是 A、B 的公共成员(交集), 一定有
x.data; // ❌ 报错 —— 万一 x 是 B 呢?B 没有 data
x.message; // ❌ 报错 —— 万一 x 是 A 呢?A 没有 message
// 想访问 data, 必须先"证明"这个 x 确实是 A:
if (x.ok === true) {
x.data; // ✅ OK —— 此处 TS 已把 x 收窄(narrow)为 A 类型
}
真相大白:不是 TypeScript 在为难我,而是我把"联合"这个词的字面意思("合并、加起来、更多")错套到了类型系统上;实际上联合类型在成员访问层面是取交集的,因为面对一个"不知道具体是哪个"的值,只有大家都有的成员访问起来才安全。我想访问只属于某一个分支的成员(data),就必须先用某种方式证明"此刻这个值确实是那个分支"——这个"证明"的动作,就是类型收窄(narrowing),也正是正解的核心。
第二件事:正解——用可辨识联合(discriminated union)先收窄再访问
根因是"面对联合类型,不收窄就只能访问公共成员",那正解的核心就一句话:访问只属于某个分支的成员前,先用一个判别条件把联合类型收窄到那个具体分支。而最优雅、最可靠的收窄方式,就是 TypeScript 的招牌——可辨识联合(discriminated union,也叫 tagged union):
// 每个分支都带一个共同的"标签"字段(字面量类型), 作为判别依据
interface SuccessResult { ok: true; data: string; } // 标签 ok: true
interface ErrorResult { ok: false; message: string; } // 标签 ok: false
type ApiResult = SuccessResult | ErrorResult;
function handle(result: ApiResult) {
if (result.ok) {
// 这个分支里 TS 已把 result 收窄为 SuccessResult
console.log(result.data); // ✅ OK
} else {
// 这个分支里 result 被收窄为 ErrorResult
console.error(result.message);// ✅ OK
}
}
这套做法的精髓,是给联合的每个分支都安排一个共同的、取字面量类型的"标签字段"(这里是 ok: true / ok: false,也常用 type: 'success' | 'error' 这种)。当你 if (result.ok) 判断这个标签时,TypeScript 能自动推断出:进入 if 分支的一定是 ok: true 的那个,于是把 result 收窄成 SuccessResult,data 就能访问了;else 分支同理收窄成 ErrorResult。"先用标签证明你面对的是哪个分支,再访问那个分支独有的成员",这就是联合类型的正确打开方式。
除了可辨识联合,收窄还有别的手段:typeof 守卫(对 string | number 这种)、instanceof(对类)、in 操作符(if ('data' in result))、以及自定义的类型谓词函数(function isSuccess(r): r is SuccessResult)。但对"成功/失败""多种事件类型"这类业务场景,带标签字段的可辨识联合是最清晰、最不易错的,还能配合 switch + 穷尽检查,漏处理一个分支编译器都会提醒。
第三件事:同一类"可能性越多、能确定的越少"的坑,我后来又咂摸出好几个
这次踩坑让我品出一个更普遍的道理:当一个东西的"可能取值范围"变大时,你能无条件确定/安全使用的部分反而会变小;要用上某个具体取值才有的东西,就必须先缩小范围、确定它到底是哪个。这个"范围越宽、确定性越少"的规律到处都是:
- 面向接口/父类型编程:把变量声明成更宽泛的父类型/接口,你就只能用父类型暴露的成员,子类特有的方法得先向下转型(类型收窄)才能用。
any反而失去类型保护:用any看似什么都能访问,实则放弃了所有检查;而unknown诚实地表达"什么都可能",所以用之前强制你先收窄。- 可空类型必须先判空:
T | null拓宽了取值(可能是 null),于是访问T的成员前必须先排除 null,否则不安全。 - 泛型未加约束就用不了成员:无约束的泛型
T可能是任何类型,所以你不能假设它有某个属性/方法,得加extends约束缩小范围后才能用。 - 越笼统的需求越难直接满足:不只是类型——一个需求/输入的可能性越大,你能不做任何假设就处理好的逻辑就越少,往往要先分类讨论。
它们的内核是同一个:"可能性的范围"和"无条件可用的确定性"是一对此消彼长的量。范围越宽(可能是更多东西之一),你能不加判断就安全使用的就越少(只剩所有可能性的公共部分);想用上只属于某个具体可能性的东西,你就必须先付出"缩小范围、确认它究竟是哪一个"的代价。所以联合类型不是"白给你更多",它是"如实告诉你这里有多种可能",而代价就是你得先把可能性收窄,才能享用具体分支的能力。我把这套关系画成了一张图(见后文)。
| 更宽的类型 | 无条件能用的 | 用具体成员要先 |
|---|---|---|
| A | B 联合类型 | 只有 A、B 公共成员(交集) | 收窄到 A 或 B |
| 父类型/接口 | 只有父类型声明的成员 | 向下转型/类型守卫 |
| unknown | 几乎什么都不能直接用 | typeof/断言收窄 |
| T | null | 不能直接访问 T 的成员 | 先判空排除 null |
| 无约束泛型 T | 只当它是 unknown 看 | 加 extends 约束 |
第四件事:联合类型 vs 交叉类型——一张对照表
这次事故还逼我把老搞反的"联合 |"和"交叉 &"彻底分清楚。它们在"值"和"成员"两个层面恰好对调:
| 维度 | 联合类型 A | B | 交叉类型 A & B |
|---|---|---|
| 值的含义 | 是 A 或 B 之一(范围更宽) | 同时是 A 又是 B(范围更窄) |
| 能安全访问的成员 | A、B 的公共成员(交集, 更少) | A、B 全部成员(并集, 更多) |
| 直觉 vs 实际 | 名字像"加"、成员却是"交" | 名字像"交"、成员却是"加" |
| 典型用途 | 多种互斥情况(成功/失败、多事件) | 能力叠加(混入 mixin、扩展) |
| 访问独有成员 | 必须先收窄到某分支 | 直接访问, 因为它两者都是 |
看清这张表,我之前的别扭就全顺了:联合 | 是"二选一",值更宽但能直接访问的成员更少(交集);交叉 & 是"两者都是",值更窄但能访问的成员更多(并集)。名字给的直觉(联合像加、交叉像减)和成员层面的实际(联合取交、交叉取并)正好相反,记住"看值还是看成员"就不会再搞混。
第五件事:我曾经对 TypeScript 联合类型想当然的几个误区
这场"属性不存在"的事故,把我对联合类型的一堆想当然照得清清楚楚:
| 我以为 | 实际上 |
|---|---|
| A | B 能访问 A 和 B 的所有字段 | 只能访问两者公共字段(交集) |
| 联合就是把字段合并、加起来 | 值层面是并集、成员访问层面是交集 |
| 报"属性不存在"是 TS 太死板 | 是保护你:万一值是另一个分支就崩了 |
| 联合类型 = 更宽松、更随便用 | 可能性越多、能无条件用的越少 |
| 访问独有字段直接 as 断言就行 | 断言是逃避检查、该用收窄安全证明 |
| 联合和交叉, 名字咋样成员就咋样 | 恰好相反, 联合取交、交叉取并 |
这些误区的根子是同一个:我被"联合"这个名字的字面直觉带偏了——"联合"听起来就是"合并、聚拢、加在一起、变多",于是我想当然地以为 A | B 会把两边的成员都给我。我没意识到,类型描述的是"一个值可能是什么",而"可能是 A 或 B"恰恰意味着"我不确定它具体是谁",不确定就只能用大家都有的——这跟"名字听起来变多"完全是两码事。把一个技术术语按它的日常字面意思去理解、而不去探究它在该领域的精确定义,是这类"反直觉"困惑的共同根源。
第六件事:用联合类型、遇到"属性不存在"时,我现在的自检习惯
现在每当我设计联合类型、或撞上"明明是联合类型却说属性不存在",我都会先分清"值"和"成员"两个层面。先看清联合类型为什么只让访问交集:
然后按这张自检图决定怎么用联合类型、怎么修报错:
配套地,我把"可辨识联合 + switch 穷尽检查"固化成了处理多分支的模板,既收窄又防漏:
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) { // 用标签 kind 判别
case 'circle': return Math.PI * s.r ** 2; // 收窄为 circle, 可用 r
case 'rect': return s.w * s.h; // 收窄为 rect, 可用 w/h
default:
const _exhaustive: never = s; // 漏了一个分支这里就编译报错
return _exhaustive;
}
}
这套习惯的精髓,是"分清值层面和成员层面、给分支加标签做可辨识联合、用 if/switch 收窄再访问独有成员、别用 as 硬绕"。它让我从"联合就是字段都能用"的错觉,变成了"联合是多种可能、用谁的成员先证明是谁"——核心始终是:TypeScript 的联合类型 A | B 要分两个层面理解,在值层面它表示一个该类型的值在运行时要么是 A 要么是 B 是其中之一但你事先并不知道具体是哪一个(所以值可能属于的类型范围更宽是并集),而正因为面对一个不知道具体是 A 还是 B 的值,编译器为了类型安全只允许你直接访问那些无论它是 A 还是 B 都一定存在的成员也就是 A 和 B 的公共成员交集(更窄)——访问只属于 A 的成员会被拒绝因为万一这个值实际是 B 就会出错;这跟联合这个名字给人的合并加起来变多的字面直觉恰好相反,可能性越多反而能无条件安全使用的越少;所以要访问只属于某个具体分支的成员就必须先把联合类型收窄 narrow 到那个分支——最优雅可靠的方式是可辨识联合 discriminated union(给每个分支都加一个共同的取字面量类型的标签字段如 ok:true/false 或 type:'success'|'error' 或 kind),然后用 if 或 switch 判断这个标签编译器就能自动把值收窄到对应分支从而安全访问其独有成员,还能在 switch 的 default 里用 never 做穷尽检查漏处理一个分支就编译报错;其它收窄手段还有 typeof 守卫 instanceof in 操作符和返回 r is T 的自定义类型谓词函数;不要用 as 类型断言去硬访问那是逃避检查不安全;另外注意联合 | 和交叉 & 在值与成员两个层面恰好对调——联合 | 值是二选一范围宽但能访问的成员是交集更少、交叉 & 值同时是两者范围窄但能访问的成员是并集更多,名字给的直觉和成员层面的实际正好相反;更一般地,一个东西的可能取值范围和你能不加判断就无条件安全使用的确定性是一对此消彼长的量(联合类型只能用交集、父类型只能用父类型成员、unknown 几乎什么都不能直接用、可空类型要先判空、无约束泛型要先 extends 约束),范围越宽能直接用的越少、想用上只属于某个具体可能性的东西就必须先付出缩小范围确认它究竟是哪一个的代价,这不是限制而是诚实——它如实告诉你这里有多种可能、逼你先把不确定性消化掉再安全地往下走。
我立下的几条规矩
这场"属性不存在"的事故,换来了我用 TypeScript 联合类型时,刻进骨子里的几条铁律:
- 联合 A | B 在值上是二选一,在能访问的成员上是取交集。
- 不收窄就只能访问公共成员,访问独有成员先收窄。
- 给每个分支加共同的字面量标签字段,做可辨识联合。
- 用 if/switch 判标签收窄,再访问该分支独有成员。
- switch + never 穷尽检查,漏处理分支让编译器报错。
- 别用 as 断言硬绕,断言是逃避检查、收窄才安全。
- 记牢联合取交、交叉取并,名字的直觉和成员实际相反。
附:一段可直接照抄的联合类型收窄速查
最后留一段我自己用联合类型时照着抄的几种收窄方式速查:
// ① 可辨识联合:加字面量标签字段, 用 if/switch 判别(首选)
type R = { ok: true; data: string } | { ok: false; message: string };
function f(r: R) {
if (r.ok) r.data; // 收窄为成功分支
else r.message; // 收窄为失败分支
}
// ② typeof 守卫:用于原始类型联合
function g(x: string | number) {
if (typeof x === 'string') x.toUpperCase(); // 收窄为 string
else x.toFixed(2); // 收窄为 number
}
// ③ in 操作符:按"有没有某个属性"判别
type Animal = { swim: () => void } | { fly: () => void };
function move(a: Animal) {
if ('swim' in a) a.swim(); // 收窄为有 swim 的那个
else a.fly();
}
// ④ instanceof:用于类的联合
function h(e: Error | string) {
if (e instanceof Error) e.message; // 收窄为 Error
else e.length; // 收窄为 string
}
// ⑤ 自定义类型谓词:把复杂判断封装成可复用的守卫
function isSuccess(r: R): r is { ok: true; data: string } { return r.ok; }
function k(r: R) { if (isSuccess(r)) r.data; }
// ⚠️ 反例:别用 as 硬绕过, 它跳过检查、运行时照样可能崩
// (r as { data: string }).data; // 不要这样!
这段速查的核心就一句:访问联合类型某个分支的独有成员前,用可辨识联合 / typeof / in / instanceof / 类型谓词中合适的一种,先把它收窄到那个分支——让编译器替你证明"此刻它确实是那个分支",而不是用 as 蒙着眼睛硬来。
写在最后
回头看,这场由"联合类型访问字段报属性不存在"引发的困惑,真正教给我的,远不止"用可辨识联合收窄"这一个技巧。它让我对"一个技术术语的名字,和它在该领域里精确的定义,有时会给人完全相反的直觉;而我们最容易栽的跟头,就是用名字的日常字面意思,去想当然地推断它的技术含义",有了一次刻骨的体会。我栽跟头,是因为我看到"联合(union)"这个词,脑子里立刻浮现出"合并、聚拢、加在一起、变多"的画面,于是想当然地以为 A | B 会把两边的字段都给我用;我压根没去深究,在类型系统里,"一个值的类型是 A 或 B"这句话,描述的是"它可能是 A,也可能是 B,但我不确定是哪个"——而"不确定是哪个",逻辑上就只能让我用"两个都保证有"的那部分;名字让我以为是"变多",定义却意味着"因为不确定所以能确定用的更少",这两者南辕北辙。这让我领悟到一个关于"名字与精确定义"的深刻认知:技术世界里的术语,绝大多数是借用日常词汇来命名的,而这种借用,往往只抓住了概念的某一个侧面,甚至会因为强调的侧面不同而误导我们;"联合"强调的是"取值范围的合并"(值可以是 A 或 B),可它给人的字面联想却是"成员的合并"(字段都能用),后者恰恰是错的;所以,理解一个技术概念,绝不能停留在它名字的字面直觉上——名字只是一个方便记忆的标签、一个可能片面甚至误导的隐喻,真正可靠的,是它在该领域里那个精确的、形式化的定义(联合类型 = 值属于这些类型的并集 ⇒ 可安全访问的成员是这些类型成员的交集);当一个概念的行为让你觉得"反直觉"时,十有八九不是这个概念设计得别扭,而是你脑子里那个由名字的字面意思生成的直觉,和它真实的定义对不上——这时候该被修正的,是你那个偷懒的直觉,而不是去抱怨概念。这给了我一种面对"一切名字听起来理所当然、用起来却反直觉的技术概念"时的清醒:每当一个概念的实际行为和我对它名字的直觉冲突,我不再急着说"这设计真反人类",而是回头去查它精确的定义到底是什么、它的名字强调的是哪个侧面、我的直觉错在哪——以精确定义校准直觉,而不是用名字的字面意思想当然;"别被术语的名字带偏、回到它精确的定义",是用对联合类型、也是真正学懂一切技术概念的关键。认清联合类型值上是并集、成员访问上是交集、要收窄才能用独有成员——这,是我用一次"明明是联合类型却不让我访问字段"的困惑,换来的、关于 TypeScript、也关于如何越过名字看清概念真实定义的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次看到 A | B 报"属性不存在"时,不再骂编译器、而是会心一笑"哦它是二选一啊,我得先收窄",那我对着那行红色波浪线懵掉的那半天,就值了。
—— 别看了 · 2026