用 Schema 建模:transform、brand 与判别联合
上一章的 Schema 还停留在「校验」:数据合不合格。这一章往前走一步,进入建模:用 Schema 表达「这个领域里什么样的值才算合法」,并让这个表达同时服务于运行时和类型系统。只引入三个概念:transform、brand、判别联合。
要解决的问题:形状对了,含义还是错的
先看几个典型的「形状正确、含义错误」的场景:
- 接口里的金额是字符串
"19.9",内部要用数字计算。外部与内部的表示不一致。 - 函数
transfer(from: string, to: string),两个参数都是字符串,调换顺序编译器不会吭声。用户 ID 和订单 ID 同为string,也可以互相传。 - 一个状态对象
{ status: string; error?: string; data?: Data },既可能「成功却带着 error」,也可能「失败却没有 error」,非法组合有无数种。
这三个问题分别对应三个工具。
transform:外部表示与内部类型之间的桥
第一类问题是外部形态与内部形态不同。上一章提到 Schema 是双向的,现在要用到这一点了:Type 是内部形态,Encoded 是外部形态,transform 定义两者怎样互相转换。
Effect 内置了常见转换,例如 Schema.NumberFromString:外部是字符串,内部是数字。
import { Schema } from "effect"
Schema.decodeUnknownSync(Schema.NumberFromString)("42") // 42
Schema.encodeSync(Schema.NumberFromString)(42) // "42"自己定义转换用 Schema.decodeTo 搭配 SchemaTransformation.transform,给出解码与编码两个方向:
import { Schema, SchemaTransformation } from "effect"
const Trimmed = Schema.String.pipe(
Schema.decodeTo(
Schema.String,
SchemaTransformation.transform({
decode: (s: string) => s.trim(),
encode: (s: string) => s
})
)
)读法是「从 String 解码到 String,解码时去掉首尾空白,编码时原样输出」。同样的思路可以用来把 ISO 字符串变成日期对象、把逗号分隔字符串变成数组。
这里的心智模型是:边界之外是外部表示,边界之内是领域表示,Schema 是两者之间唯一的翻译处。 内部代码拿到的永远是已转换好的值,不需要在业务里写 new Date(x) 或 Number(x) 这类零散转换。转换要遵守一条纪律:它应该是纯的、可预期的,不要在里面做 IO。
brand:给形状相同的值不同的身份
第二类问题要用品牌类型(brand)解决。它的思想是:虽然两个值在运行时都是数字,但在类型上它们是不同的东西。
import { Schema } from "effect"
const UserId = Schema.String.pipe(Schema.brand("UserId"))
const OrderId = Schema.String.pipe(Schema.brand("OrderId"))
type UserId = typeof UserId.Type
type OrderId = typeof OrderId.Type
declare function cancel(order: OrderId): void
// cancel(someUserId) // 编译错误:UserId 不能当作 OrderIdbrand 还可以叠在约束之上,把「通过了校验」这个事实记在类型里:
const Cents = Schema.Int.pipe(Schema.brand("Cents"))
const Email = Schema.String.check(Schema.isPattern(/^[^@]+@[^@]+$/)).pipe(
Schema.brand("Email")
)
const price = Cents.make(1999) // 通过校验才能得到 Cents这样一来,拿到 Email 类型的值,就等于拿到一份「它已经被验证过」的证据。函数签名写 sendTo(email: Email),就不必在函数里再检查格式。这是第 7 章「边界校验一次、内部信任类型」的自然延伸:信任需要有载体,brand 就是载体。
有一点必须清楚:brand 是编译期的名义区分,运行时它还是原来的字符串或数字。它的可靠性取决于「值只能经由 make 或解码得到」。如果你在代码里写 x as Email,就绕过了所有保证。所以项目里要守住一条线:构造品牌值只能走 Schema,不能手写断言。
判别联合:让非法状态无法表示
第三类问题是状态建模。把 { status; error?; data? } 改成一组各自带标签的结构,每个变体只含自己合法的字段:
import { Schema } from "effect"
const LoadState = Schema.Union([
Schema.TaggedStruct("Loading", {}),
Schema.TaggedStruct("Success", { data: Schema.String }),
Schema.TaggedStruct("Failure", { message: Schema.String })
])
type LoadState = typeof LoadState.Type
const render = (r: LoadState) => {
switch (r._tag) {
case "Loading": return "..."
case "Success": return r.data // 此处 r 一定有 data
case "Failure": return r.message // 此处 r 一定有 message
}
}Schema.TaggedStruct 为每个变体注入一个 _tag 字段,Schema.Union 把它们并起来。switch 里 TypeScript 会据 _tag 自动收窄类型,漏掉一种变体时(配合穷尽检查)编译器会提醒你。「成功却带着 error」这种组合,在类型里根本写不出来。
这和第 5 章的 TaggedError 是同一个套路:_tag 是判别字段,catchTag 在失败上做的事,switch 在数据上做。错误与数据共用同一套「带标签的联合」代数。 第 5 章提到的 Schema.TaggedError 就是把这套带标签的结构用在错误上:字段由 Schema 描述,所以错误也能编码、解码,适合跨进程传输。
枚举式的取值用 Schema.Literals:
const Role = Schema.Literals(["admin", "member", "guest"])
// 解码时只接受这三个字符串之一一个判断:当你发现自己在写「字段 A 只有在字段 B 为某值时才有意义」,就该把它拆成联合了。可选字段越堆越多,往往是联合的信号。
三者合起来
假设要建模一笔订单:金额用 Cents(brand),创建时间在传输中是字符串、内部是日期(transform),状态用判别联合(Pending / Paid / Refunded,各带自己的字段)。解码一次,得到的就是一个所有字段都有含义、所有状态都合法的值;此后整个代码库只和这个领域类型打交道。Schema 既是边界的校验器,又是领域的词汇表。
常见误区
- 把 brand 当成运行时保证。 它只是类型层的标记,
as一下就破。运行时的保证来自make与解码里的校验。 - 在 transform 里做 IO 或有副作用的事。 转换应该是纯映射。需要查库校验的规则不属于 Schema 的转换。
- 用一堆可选字段代替联合。 字段的组合爆炸,非法状态随之爆炸。
- 用
string代替Literals。 能取的值明明有限,却声明成任意字符串,等于放弃了穷尽检查。 - 给每个字符串都加 brand。 品牌是为了防止真正会混淆、混淆代价高的值(各类 ID、金额单位)。滥用只会增加噪音。
- 在内部随处做转换。 外部与内部的转换应当集中在 Schema 里,而不是分散在业务代码中。
小结
transform 负责外部表示与内部类型的互转,brand 把「已验证」「不可混用」写进类型,判别联合让非法状态无从表示。三者共同的方向是:把规则从注释和约定,搬到类型和 Schema 里。
Part 2 到此结束:我们能描述「怎样失败」(第 5、6 章),也能描述「数据长什么样」(第 7、8 章)。Part 3 转向结构问题:程序里的各个部分怎样互相依赖。下一章从服务(service)开始,见 第 9 章。