Skip to content

Commit 1fae784

Browse files
authored
feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms (#23222)
1 parent 81b7b58 commit 1fae784

2 files changed

Lines changed: 157 additions & 2 deletions

File tree

packages/opencode/src/util/effect-zod.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,43 @@ import z from "zod"
88
*/
99
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
1010

11+
/**
12+
* Annotation key for a pre-parse transform that runs on the raw input before
13+
* the derived Zod schema validates it. The walker emits
14+
* `z.preprocess(fn, inner)` when this annotation is present.
15+
*
16+
* Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema
17+
* needs to inspect the user's raw input (e.g. to capture insertion order)
18+
* before `Schema.Struct` canonicalises the object.
19+
*
20+
* TODO: This exists to paper over a missing Effect Schema feature. The
21+
* parser canonicalises open struct output (known fields first in
22+
* declaration order, then catchall fields) before any user-defined
23+
* transform sees the value, and there is no pre-parse hook — so the
24+
* user's original property insertion order is gone by the time
25+
* `Schema.decodeTo` or `middlewareDecoding` runs.
26+
*
27+
* That canonicalisation is a reasonable default, but `config/permission.ts`
28+
* encodes rule precedence in the user's JSON key order (`evaluate.ts`
29+
* uses `findLast`, so later entries win), which the canonicalisation
30+
* silently destroys.
31+
*
32+
* The cleanest upstream fix would be either:
33+
*
34+
* 1. A `preserveInputOrder` option on `Schema.Struct` /
35+
* `Schema.StructWithRest` that keeps the input's insertion order in
36+
* the parsed object (opt-in; canonical order stays default).
37+
* 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a
38+
* transformation whose decode receives the raw `unknown`).
39+
*
40+
* Either of those would let us delete `ZodPreprocess` and the
41+
* `__originalKeys` hack. Alternatively, the permission model could move
42+
* to specificity-based precedence (exact keys beat wildcards) or an
43+
* explicit ordered array of rules, which removes the ordering
44+
* dependency at the data-model level.
45+
*/
46+
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
47+
1148
// AST nodes are immutable and frequently shared across schemas (e.g. a single
1249
// Schema.Class embedded in multiple parents). Memoizing by node identity
1350
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
@@ -47,7 +84,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
4784
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
4885
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
4986
const base = hasTransform ? encoded(ast) : body(ast)
50-
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
87+
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
88+
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
89+
const out = preprocess ? z.preprocess(preprocess, checked) : checked
5190
const desc = SchemaAST.resolveDescription(ast)
5291
const ref = SchemaAST.resolveIdentifier(ast)
5392
const described = desc ? out.describe(desc) : out

packages/opencode/test/util/effect-zod.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
22
import { Effect, Schema, SchemaGetter } from "effect"
33
import z from "zod"
44

5-
import { zod, ZodOverride } from "../../src/util/effect-zod"
5+
import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod"
66

77
function json(schema: z.ZodTypeAny) {
88
const { $schema: _, ...rest } = z.toJSONSchema(schema)
@@ -751,4 +751,120 @@ describe("util.effect-zod", () => {
751751
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
752752
})
753753
})
754+
755+
describe("ZodPreprocess annotation", () => {
756+
test("preprocess runs on raw input before the inner schema parses", () => {
757+
// Models the permission.ts __originalKeys pattern: capture the original
758+
// insertion order of a user-provided object BEFORE Schema parsing
759+
// canonicalises the keys.
760+
const preprocess = (val: unknown) => {
761+
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
762+
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
763+
}
764+
return val
765+
}
766+
const Inner = Schema.Struct({
767+
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
768+
a: Schema.optional(Schema.String),
769+
b: Schema.optional(Schema.String),
770+
}).annotate({ [ZodPreprocess]: preprocess })
771+
772+
const schema = zod(Inner)
773+
const parsed = schema.parse({ b: "1", a: "2" }) as {
774+
__keys?: string[]
775+
a?: string
776+
b?: string
777+
}
778+
expect(parsed.__keys).toEqual(["b", "a"])
779+
expect(parsed.a).toBe("2")
780+
expect(parsed.b).toBe("1")
781+
})
782+
783+
test("preprocess does not transform already-shaped input", () => {
784+
// When the user passes an object that already has __keys, preprocess
785+
// returns it unchanged because spreading preserves any existing key.
786+
const preprocess = (val: unknown) => {
787+
if (typeof val === "object" && val !== null && !("__keys" in val)) {
788+
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
789+
}
790+
return val
791+
}
792+
const Inner = Schema.Struct({
793+
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
794+
a: Schema.optional(Schema.String),
795+
}).annotate({ [ZodPreprocess]: preprocess })
796+
797+
const schema = zod(Inner)
798+
const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as {
799+
__keys?: string[]
800+
a?: string
801+
}
802+
expect(parsed.__keys).toEqual(["existing"])
803+
})
804+
805+
test("preprocess composes with a union (either object or string)", () => {
806+
// Mirrors permission.ts exactly: input can be either an object (with
807+
// preprocess injecting metadata) or a plain string action.
808+
const Action = Schema.Literals(["ask", "allow", "deny"])
809+
const Obj = Schema.Struct({
810+
__keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
811+
read: Schema.optional(Action),
812+
write: Schema.optional(Action),
813+
})
814+
const preprocess = (val: unknown) => {
815+
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
816+
return { __keys: Object.keys(val), ...(val as Record<string, unknown>) }
817+
}
818+
return val
819+
}
820+
const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess })
821+
const schema = zod(Inner)
822+
823+
// String branch — passes through preprocess unchanged
824+
expect(schema.parse("allow")).toBe("allow")
825+
826+
// Object branch — __keys injected, preserves order
827+
const parsed = schema.parse({ write: "allow", read: "deny" }) as {
828+
__keys?: string[]
829+
read?: string
830+
write?: string
831+
}
832+
expect(parsed.__keys).toEqual(["write", "read"])
833+
expect(parsed.write).toBe("allow")
834+
expect(parsed.read).toBe("deny")
835+
})
836+
837+
test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => {
838+
const Inner = Schema.Struct({
839+
a: Schema.optional(Schema.String),
840+
b: Schema.optional(Schema.Number),
841+
}).annotate({ [ZodPreprocess]: (v: unknown) => v })
842+
const shape = json(zod(Inner)) as any
843+
expect(shape.type).toBe("object")
844+
expect(shape.properties.a.type).toBe("string")
845+
expect(shape.properties.b.type).toBe("number")
846+
})
847+
848+
test("identifier + description propagate through the preprocess wrapper", () => {
849+
const Inner = Schema.Struct({
850+
x: Schema.optional(Schema.String),
851+
})
852+
.annotate({
853+
identifier: "WithPreproc",
854+
description: "A schema with preprocess",
855+
[ZodPreprocess]: (v: unknown) => v,
856+
})
857+
const schema = zod(Inner)
858+
expect(schema.meta()?.ref).toBe("WithPreproc")
859+
expect(schema.meta()?.description).toBe("A schema with preprocess")
860+
})
861+
862+
test("preprocess inside a struct field applies only to that field", () => {
863+
const Inner = Schema.String.annotate({
864+
[ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v),
865+
})
866+
const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number }))
867+
expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 })
868+
})
869+
})
754870
})

0 commit comments

Comments
 (0)