Skip to content

Commit 9530127

Browse files
committed
feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms
Models zod's z.preprocess(fn, schema) pattern \u2014 useful when the schema needs to inspect the user's raw input (e.g. to capture insertion order) before Schema.Struct canonicalises the object. The walker emits z.preprocess(fn, inner) when the ZodPreprocess annotation is present. JSON Schema output comes from the inner schema; the preprocess is runtime-only. Unblocks the config/permission.ts migration \u2014 the __originalKeys hack used to preserve permission rule order needs a pre-parse hook that Effect Schema does not expose directly.
1 parent a5d99e7 commit 9530127

2 files changed

Lines changed: 131 additions & 2 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ 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+
export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess")
21+
1122
// AST nodes are immutable and frequently shared across schemas (e.g. a single
1223
// Schema.Class embedded in multiple parents). Memoizing by node identity
1324
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
@@ -47,7 +58,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
4758
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
4859
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
4960
const base = hasTransform ? encoded(ast) : body(ast)
50-
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
61+
const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
62+
const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess]
63+
const out = preprocess ? z.preprocess(preprocess, checked) : checked
5164
const desc = SchemaAST.resolveDescription(ast)
5265
const ref = SchemaAST.resolveIdentifier(ast)
5366
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)