返回博客

TypeScript in 收窄被 ?: never 悄悄废掉了

重构一个图标组件,联合类型写得「很严谨」——两条分支互相用 ?: never 封死对方的字段——结果 "src" in item 守卫下的分支里,访问 srcSetwidthalt 全是 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 对两条分支都可能为 truesrc 在两条上都有声明),收窄无从发生,联合类型保持原样。

坑:`?: never` 强调互斥,却让 `in` 收窄失效

?: 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") { /* 完美收窄 */ }
正解:删掉 `?: never`,让 key 只属于它该在的分支

in 收窄的充要条件是「key 只在部分成员上存在」。用 ?: never 交叉声明等于把 key 放到了所有成员上,必须删掉。如果一定要保留互斥语义,用显式 kind tag 字段是最清晰的方案。

一句话外卖

?: never 只能阻止赋值,无法阻止 in 看到这个 key;要让 in 收窄生效,就不要用 ?: never 交叉声明。