Schema.Class、Opaque 和 TaggedUnion 怎么选?

我想让联合成员带方法,写了 TaggedClass,结果 guards.Circle({ _tag: "Circle", radius: 1 }) 返回 false,结构明明一模一样。换成 Opaque 以为能隔开两种结构相同的数据,结果字面量照样能赋值。

判断标准只有一个:运行时需要这个身份吗?不需要就用 Schema.TaggedUnion;成员要方法或 instanceof 用 Class;只想在类型层面隔开,用带 Brand 的 Opaque;单个原始类型的名义化用 Schema.brand。

这三个工具名字看起来相近,行为差别却很大。TaggedUnion 的定义写法见 Effect 里怎么用 Schema.TaggedUnion 定义判别联合?。下面逐个看,另外 Class 成员要靠 toTaggedUnion 串成联合,它的用法见 toTaggedUnion 怎么给已有联合加 match 和 guards?。

TaggedClass 的联合守卫为什么不认普通对象?

import { Schema } from "effect"

class Circle extends Schema.TaggedClass<Circle>()("Circle", { radius: Schema.Number }) {
  area() {
    return Math.PI * this.radius ** 2
  }
}
class Rect extends Schema.TaggedClass<Rect>()("Rect", { w: Schema.Number, h: Schema.Number }) {}

const Shape = Schema.Union([Circle, Rect]).pipe(Schema.toTaggedUnion("_tag"))
const s = Schema.decodeUnknownSync(Shape)({ _tag: "Circle", radius: 1 })
console.log(s, Shape.match(s, { Circle: (c) => c.area(), Rect: (r) => r.w * r.h }))
// Circle { _tag: 'Circle', radius: 1 } 3.141592653589793
console.log(Shape.guards.Circle(new Circle({ radius: 1 }))) // true
console.log(Shape.guards.Circle({ _tag: "Circle", radius: 1 })) // false

TaggedClass 解码后得到的是真正的类实例,可以有方法,match 里直接调用 c.area()。代价在最后一行:guards.Circle 对 Class 成员是 instanceof 语义,结构相同的普通对象不通过。所以带 Class 的联合,数据必须经过 decode 或 new 才能进入,不能手写对象字面量冒充。

Opaque 的运行时值是什么?

class Money extends Schema.Opaque<Money>()(
  Schema.Struct({ amount: Schema.Int, currency: Schema.Literals(["CNY", "USD"]) })
) {}

const m = Schema.decodeUnknownSync(Money)({ amount: 1, currency: "CNY" })
console.log(m, m instanceof Money) // { amount: 1, currency: 'CNY' } false

普通对象,instanceof 为 false。Opaque 只改变 TypeScript 里的类型名,不加任何运行时东西。

Opaque 能提供名义类型吗?

默认的 Opaque<Money>() 不提供名义类型,结构相同的字面量照样能赋给 Money。真想隔离,要传第二个类型参数当 Brand:

class Usd extends Schema.Opaque<Usd, { readonly _brand: "Usd" }>()(
  Schema.Struct({ amount: Schema.Int })
) {}

// 不带 Brand:结构相同的字面量仍然可以赋值
const takesMoney = (x: Money) => x.amount
takesMoney({ amount: 1, currency: "CNY" })

// 带 Brand:字面量被拒绝,必须经由 Usd.make 或解码得到
const takesUsd = (x: Usd) => x.amount
// @ts-expect-error 缺少品牌
takesUsd({ amount: 1 })
takesUsd(Usd.make({ amount: 1 }))

Opaque 能包原始类型吗?

不能,它只能包 Struct。class X extends Schema.Opaque<X>()(Schema.String) 会得到 Base constructor return type 'string' is not an object type 的编译错误,我还试了 Union 包两个 Struct,同样不行。根源是 TypeScript 只允许 class extends 一个对象类型。要给原始类型加名义身份,用 Schema.brand,见 Effect Schema 的 brand 怎么防止 ID 传错?。

到底怎么选?

  • 状态只是数据、要序列化、要被 match 穷尽:Schema.TaggedUnion。这是状态机的默认选择。
  • 成员需要方法、需要 instanceof:Class 或 TaggedClass,再用 toTaggedUnion 串起来。
  • 只想在类型层面把两种结构相同的数据隔开,运行时仍然是纯对象:Opaque 加 Brand。
  • 单个原始类型的名义化:brand,不是 Opaque。

多数领域状态机,我会先用 TaggedUnion,因为它的值是可以直接 JSON.stringify 的普通对象,测试里也能直接写字面量。只有当某个状态确实长出了行为,才把那一个成员升级成 Class,联合的其它成员不用动。

版本是 effect 4.0.5。文中代码在 strict 加 exactOptionalPropertyTypes 下编译并运行过,相关 API 的 JSDoc 都标注为 @stability stable。Opaque 包 Schema.String 和包 Union 的编译错误是我亲自试过的;「根源是 TypeScript 只允许 class extends 对象类型」是我的归因,没有查证过。