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。