Effect Optic 的 check 在写回时会校验吗?
在 optic 的焦点上挂一个 Schema.isPattern(/^\d{6}$/),然后往里写 "12",replaceResult 返回的是 Success,值也真的写进去了。
不会校验:.check(...) 只管读,getResult 在 check 失败时失败,写回时 set 原样透传。 想在写入时拒绝非法值,有三条路:写后复核、用 makeOptional 自定义带校验的 setter、或者走 Schema.toIso。
原因在于 check 的角色是「这个焦点只在满足条件时才存在」,这对按条件筛 Traversal 里的元素很有用,但它不是写入守卫。Optic 是 effect v4 里 Optic.id<S>() 起手、链式钻字段、replace 写回新值的不可变更新工具,基本用法见 Effect Optic 怎么深层读写不可变数据?。下面先确认现象,再看三种拦截方式。
check 在读侧和写侧分别做什么?
.check(...) 接受 Schema 的 check(Schema.isGreaterThan、Schema.isMinLength、Schema.isPattern 等)。官方 JSDoc 的描述是:getResult 在 check 失败时失败,set 原样透传。我跑过下面的代码,行为一致:
import { Optic, Result, Schema, SchemaIssue } from "effect"
type Order = { readonly address: { readonly zip: string } }
const order: Order = { address: { zip: "310000" } }
const _zip = Optic.id<Order>()
.key("address")
.key("zip")
.check(Schema.isPattern(/^\d{6}$/))
// 读侧:不合格的值读不出来
const bad: Order = { address: { zip: "x" } }
const read = _zip.getResult(bad)
if (Result.isFailure(read)) {
console.log(SchemaIssue.makeFormatterDefault()(read.failure))
// Expected a string matching the RegExp ^\d{6}$
}
// 写侧:非法值照样写进去,replaceResult 也是 Success
const written = _zip.replaceResult("12", order)
console.log(written._tag) // Success
console.log(_zip.replace("12", order).address.zip) // 12所以「挂一个 check 就能保证写回的值合法」是错误的直觉。读侧它把不合格的值挡在焦点之外,写侧它什么都不做。
怎么在写回之后复核?
最简单的办法:写完再用同一个 optic 读一遍,读不出来就说明写入的值不满足条件。
import { Optic, Result, Schema } from "effect"
type Order = { readonly address: { readonly zip: string } }
const order: Order = { address: { zip: "310000" } }
const _zip = Optic.id<Order>().key("address").key("zip").check(Schema.isPattern(/^\d{6}$/))
const next = _zip.replace("12", order)
console.log(Result.isFailure(_zip.getResult(next))) // true:写入的值不满足条件这条路不需要新概念,但校验要靠调用方记得做,每个写入点都得写一遍。
怎么把校验收进 optic 本身?
用 makeOptional 自己定义带校验的 setter,这样所有调用方共用同一份规则。这里用 SchemaParser.decodeUnknownResult 拿到 Result<_, Issue>,正好对上 makeOptional 要求的错误类型:
import { Optic, Result, Schema, SchemaIssue, SchemaParser } from "effect"
type Order = { readonly address: { readonly zip: string } }
const order: Order = { address: { zip: "310000" } }
const validated = <S, A>(optic: Optic.Optional<S, A>, schema: Schema.Codec<A, A>): Optic.Optional<S, A> =>
Optic.makeOptional(optic.getResult, (a, s) =>
Result.flatMap(SchemaParser.decodeUnknownResult(schema)(a), (ok) => optic.replaceResult(ok, s)))
const Zip = Schema.String.check(Schema.isPattern(/^\d{6}$/))
const _zip = validated(Optic.id<Order>().key("address").key("zip"), Zip)
const bad = _zip.replaceResult("12", order)
if (Result.isFailure(bad)) console.log(SchemaIssue.makeFormatterDefault()(bad.failure))
// Expected a string matching the RegExp ^\d{6}$
console.log(_zip.replace("12", order).address.zip) // 310000:replace 保持原值
console.log(_zip.replace("100000", order).address.zip) // 100000注意最后两行:replace 在校验失败时保持原值,和 optic「失败即不变」的约定一致;要看到失败原因必须走 replaceResult。
Schema.toIso 能在写回时校验吗?
能,而且是最彻底的一种。Schema 与 Optic 有两处连接:Schema.Optic<T, Iso> 接口(大部分原语 schema 都实现了它),以及 Schema.toIso、Schema.toIsoSource、Schema.toIsoFocus 三个把 schema 变成 Iso optic 的函数。
Schema.toIso(schema) 的特点是:通过它读是编码,通过它写是解码。所以写回会走完整的 schema 校验,并且产出的是真正的 class 实例。
import { Schema } from "effect"
class Account extends Schema.Class<Account>("Account")({
name: Schema.String,
balance: Schema.Number.check(Schema.isGreaterThanOrEqualTo(0))
}) {}
const a = new Account({ name: "a", balance: 10 })
const _balance = Schema.toIso(Account).key("balance")
const b = _balance.replace(5, a)
console.log(b instanceof Account, b.balance) // true 5
try {
_balance.replace(-1, a)
} catch (e) {
console.log((e as Error).message) // Schema validation failed
}代价是失败的表现形式变了:校验失败是同步抛异常(Error,cause 里放 SchemaIssue),连 replaceResult 也一样抛,不会返回 Result.Failure。JSDoc 的 Gotchas 里明确提到了这一点:这条路径是同步的,涉及失败、异步或依赖服务的转换时会抛。所以它适合在边界处用 try/catch 或 Effect.try 包一层,不适合当作返回值式的校验。
class 实例上能直接用 Optic.id 写回吗?
不能。直接对 class 实例用 Optic.id<Account>().key("balance").replace(...) 会抛 Cannot clone object with non-Object constructor or null prototype,因为 .key 的写回靠浅克隆普通对象。读(get)没问题,写不行。
对 class 值请走上一节的 Schema.toIso(...),或者先转成普通结构。
版本是 effect 4.0.5,代码在strict加exactOptionalPropertyTypes下编译并实际运行过:包括 check 写侧透传、写后复核、makeOptional自定义 setter、Schema.toIso的同步抛错,以及 class 实例上.key写回抛错。getResult失败与set透传的语义描述,来自官方 JSDoc。