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 })) // falseTaggedClass 解码后得到的是真正的类实例,可以有方法,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 对象类型」是我的归因,没有查证过。