TypeScript in 收窄被 ?: never 悄悄废掉了
重构一个图标组件,联合类型写得「很严谨」——两条分支互相用 ?: never 封死对方的字段——结果 "src" in item 守卫下的分支里,访问 srcSet、width、alt 全是 TS2339,仿佛 in 守卫什么都没做。
现象
类型定义长这样,典型的「互斥联合」写法:
type LogoItem =
| { src: string; srcSet?: string; width?: number; node?: never }
| { node: ReactNode; ariaLabel?: string; src?: never };然后用 in 操作符收窄:
if ("src" in item) {
// 期望此处 item 是第一条分支
return <img src={item.src} srcSet={item.srcSet} width={item.width} />;
// ^^^^^^^^^^^^^^^^^^^ TS2339
// ^^^^^^^^^ TS2339
}tsc --noEmit 一跑,6 个 TS2339 集中爆发在 "src" in item 内部,全是第一条分支独有的字段。IDE 也同步飘红。
根因
in 操作符的收窄规则只有一个前提:被检测的 key 在部分成员上存在、在另一部分成员上不存在。TypeScript 才能据此排除不含该 key 的成员。
问题出在 node?: never 这行。?: never 的语义是:这个 key 存在于该类型,只是值的类型是 never(运行时是 undefined)。于是两条分支里 src 都是已声明的 key:
- 分支 A:
src: string - 分支 B:
src?: never(即src?: undefined)
对 TypeScript 来说,"src" in item 对两条分支都可能为 true(src 在两条上都有声明),收窄无从发生,联合类型保持原样。
?: never 在语义上表达「这个字段不应该有值」,但在结构类型系统里它等于「这个 key 存在于类型上」。in 操作符只看 key 存不存在,不看类型是不是 never。所以 ?: never 交叉声明恰好把 in 收窄的前提条件破坏掉了——所有成员都含有该 key,收窄无效。
这是一个反直觉的坑:越「严谨」的互斥写法,越让 in 收窄失效。
正解
方案一:删掉 ?: never 交叉声明(推荐)
让每个字段只出现在它所属的分支上,in 就能正常区分:
type LogoItem =
- | { src: string; srcSet?: string; width?: number; node?: never }
- | { node: ReactNode; ariaLabel?: string; src?: never };
+ | { src: string; srcSet?: string; width?: number }
+ | { node: ReactNode; ariaLabel?: string };删掉交叉声明后,"src" in item 能把第二条分支排除,item.srcSet 等字段全部类型安全。运行时行为完全不变——那些 ?: never 字段本来就从未被赋值过。
方案二:按值收窄,而不是按 key
如果因为历史原因不想改类型定义,可以改用值判断:
if (item.src != null) {
// item.src 是 string,收窄成功
}item.src 在分支 B 类型是 never(运行时 undefined),!= null 自然为 false,收窄语义等价。
方案三:加显式 tag 字段
规模更大的联合类型,最稳的还是加字面量标签:
type LogoItem =
| { kind: "img"; src: string; srcSet?: string; width?: number }
| { kind: "node"; node: ReactNode; ariaLabel?: string };
if (item.kind === "img") { /* 完美收窄 */ }in 收窄的充要条件是「key 只在部分成员上存在」。用 ?: never 交叉声明等于把 key 放到了所有成员上,必须删掉。如果一定要保留互斥语义,用显式 kind tag 字段是最清晰的方案。
一句话外卖
?: never只能阻止赋值,无法阻止in看到这个 key;要让in收窄生效,就不要用?: never交叉声明。