DEBUG.WIKI
搬瓦工;先看线路,再选 VPS;CN2 GIA · 对应线路套餐;查看线路 →
野草云;香港 200Mbps 峰值;不限月流量 · BGP VPS;查看套餐 →
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS

TypeScript Exclude 排除一个子类后变成 never:类名不是类型标记

两个子类的成员结构能互相兼容时,Exclude 可能把整个联合排空。用可判断的类型差异表达身份,并区分纯类型标记与运行时字段。

Exclude<B | C, C> 原本想留下 B,编辑器却把结果显示成 never。如果 C 只继承了基类,没有增加实例成员,而 B 比它多一个字段,这通常是结构兼容的结果。

TypeScript 6.0.3 中可见这个行为。类的名字、构造参数和 extends 关系,不会自动成为可区分的实例类型标记;不能按 Java 等语言的类身份理解这里的排除。

先比较实例实际有哪些成员

下面是类型关系示例:

class Point {
  constructor(public x: number, public y: number) {}
}
class NamedPoint extends Point {
  constructor(x: number, y: number, public name: string) {
    super(x, y);
  }
}
class PlainPoint extends Point {}

type Remaining = Exclude<NamedPoint | PlainPoint, PlainPoint>;
// never

PlainPoint 的实例要求有 x、y。NamedPoint 也有这两个成员,多出 name 并不会阻止它赋给 PlainPoint。Exclude 逐个检查联合成员是否能赋给待排除类型;两个分支都满足条件,最后就没有剩余成员。

构造函数属于类的静态一侧,上面的工具类型处理的是实例类型。仅改变构造参数或再写一个空子类,无法表达想要的身份差别。

确实需要区分时,把差别写进类型

如果业务只需要静态区分,且对象确实总由相应构造流程创建,可以给不同子类增加各自的纯类型成员。以下片段替换上例的两个子类,基类保持不变:

class NamedPoint extends Point {
  declare namedPointBrand: undefined;
  constructor(x: number, y: number, public name: string) {
    super(x, y);
  }
}
class PlainPoint extends Point {
  declare plainPointBrand: undefined;
}

type Remaining = Exclude<NamedPoint | PlainPoint, PlainPoint>;
// NamedPoint

declare 字段不生成对应 JavaScript 初始化。这能改变类型结构,却不会在运行时创建 plainPointBrand 属性,因此不能再用该属性的存在性来分辨实际对象,也不能把它序列化后当作身份凭证。

如果程序要在运行时按类别分支,应使用真实且有意义的判别字段,或在保证实例来源的边界使用合适的实例判断。不要随手加一个没有业务含义的数字字段,再让其他代码误以为它有实际用途。修改公共类的结构也会影响下游对象字面量和已有赋值,需一并审查。

验收可赋值关系,而不是只看悬浮提示

用项目实际编译器检查 Remaining 是否接受预期子类、拒绝被排除的子类,再查看 JavaScript 产物没有新增纯类型字段的初始化。如果 API 要接收反序列化对象,重新确认这类对象怎样满足契约;类型标记不能验证外部数据。

这不是靠升级消除的普通编译回归。若两个类本来就应当结构兼容,保留兼容关系并调整 API 建模,比强行让 Exclude 留下一个名字更符合原来的契约。