怎么用 Schema.TaggedUnion 写订单状态机?
订单状态机最容易出的 bug 是漏处理:新增一个状态,却忘了某个转换函数里还有 default 悄悄吞掉它。
做法是状态和事件各定义一个 Schema.TaggedUnion,转换写成 Event.match 里用 guards 或 isAnyOf 判断当前状态的纯函数,非法转换返回 Result.fail。两层分发都是穷尽的,少处理任何一个状态或事件,编译器直接报错。
下面的代码用到 Schema.TaggedUnion 的 match、guards、isAnyOf,它们各自检查什么,见 Effect 里怎么用 Schema.TaggedUnion 定义判别联合?。这里直接拿来组合。
状态机的完整代码长什么样?
状态是 Order,事件是另一个 TaggedUnion,转换是一个 match 加 guards 的纯函数:
import { Result, Schema } from "effect"
export const Order = Schema.TaggedUnion({
Draft: { items: Schema.Array(Schema.String) },
Paid: { items: Schema.Array(Schema.String), paidAt: Schema.DateTimeUtcFromString },
Shipped: { items: Schema.Array(Schema.String), trackingNo: Schema.NonEmptyString },
Cancelled: { reason: Schema.String }
})
export type Order = typeof Order.Type
const Event = Schema.TaggedUnion({
Pay: { at: Schema.DateTimeUtcFromString },
Ship: { trackingNo: Schema.NonEmptyString },
Cancel: { reason: Schema.String }
})
type Event = typeof Event.Type
const next = (o: Order, e: Event): Result.Result<Order, string> =>
Event.match(e, {
Pay: (ev) =>
Order.guards.Draft(o)
? Result.succeed<Order>({ _tag: "Paid", items: o.items, paidAt: ev.at })
: Result.fail(`cannot pay from ${o._tag}`),
Ship: (ev) =>
Order.guards.Paid(o)
? Result.succeed<Order>({ _tag: "Shipped", items: o.items, trackingNo: ev.trackingNo })
: Result.fail(`cannot ship from ${o._tag}`),
Cancel: (ev) =>
Order.isAnyOf(["Draft", "Paid"])(o)
? Result.succeed<Order>({ _tag: "Cancelled", reason: ev.reason })
: Result.fail(`cannot cancel from ${o._tag}`)
})
const draft: Order = { _tag: "Draft", items: ["book"] }
const ev = Schema.decodeUnknownSync(Event)({ _tag: "Pay", at: "2026-10-22T00:00:00Z" })
console.log(next(draft, ev))
console.log(next({ _tag: "Cancelled", reason: "x" }, ev))为什么两层分发都是穷尽的?
事件少处理一种,Event.match 报错;状态加一种,凡是对 Order 做 match 的地方报错。「当前状态是否允许这个事件」写成 guards 加三元,非法转换变成显式的失败值,状态机的转换表一眼就能读出来。
错误值这里只是字符串。要把失败建模成带标签的错误类型,可以参考类型化错误。
外部事件怎么安全地进入状态机?
事件同样来自 Schema.decodeUnknownSync,所以从外部进来的事件和落库的状态走的是同一套校验,这也正是边界解码的用法:外部数据先解码成类型安全的值,再交给 next。
同一份定义还能导出什么?
TaggedUnion 的返回值是普通 Schema,所以 Effect Schema 工具链里的整套投影都能直接用。我对一个只含 Draft 和 Paid 的子集试过:Schema.toCodecJson 编码后是 {"_tag":"Paid","items":[],"paidAt":"2026-10-22T00:00:00.000Z"},toJsonSchemaDocument 生成的是每个成员一个 object 的 anyOf,_tag 是单值 enum,toFormatter 能直接把状态打成日志。
也就是说,状态定义一旦写好,落库格式、给前端的 JSON Schema、审计日志格式都是同一个来源。新增一个状态时,三者同时更新,match 的编译错误会告诉你还有哪些业务分支没处理。
版本是 effect 4.0.5。文中代码在 strict 加exactOptionalPropertyTypes下编译并运行过,相关 API 的 JSDoc 都标注为@stability stable。导出部分只对Draft和Paid的子集试过,含四个状态的完整Order没有单独验证。