Skip to content

Commit 36119ff

Browse files
authored
feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207)
1 parent bb90f3b commit 36119ff

2 files changed

Lines changed: 129 additions & 6 deletions

File tree

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

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
4040
// Declarations fall through to body(), not encoded(). User-level
4141
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
4242
// nodes, where we do apply the transform.
43-
const hasTransform = ast.encoding?.length && ast._tag !== "Declaration"
43+
//
44+
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
45+
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
46+
// encoding resolves a default from Option.none() route through body()/opt().
47+
const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration"
48+
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
4449
const base = hasTransform ? encoded(ast) : body(ast)
4550
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
4651
const desc = SchemaAST.resolveDescription(ast)
@@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
217222
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
218223
if (ast._tag !== "Union") return fail(ast)
219224
const items = ast.types.filter((item) => item._tag !== "Undefined")
220-
if (items.length === 1) return walk(items[0]).optional()
221-
if (items.length > 1)
222-
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
223-
return z.undefined().optional()
225+
const inner =
226+
items.length === 1
227+
? walk(items[0])
228+
: items.length > 1
229+
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
230+
: z.undefined()
231+
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
232+
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
233+
// it to extract the default and emit `.default(...)` instead of `.optional()`.
234+
const fallback = extractDefault(ast)
235+
if (fallback !== undefined) return inner.default(fallback.value)
236+
return inner.optional()
237+
}
238+
239+
type DecodeLink = {
240+
readonly transformation: {
241+
readonly decode: {
242+
readonly run: (
243+
input: Option.Option<unknown>,
244+
options: SchemaAST.ParseOptions,
245+
) => Effect.Effect<Option.Option<unknown>, unknown>
246+
}
247+
}
248+
}
249+
250+
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
251+
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
252+
if (!encoding?.length) return undefined
253+
// Walk the chain of encoding Links in order; the first Getter that produces
254+
// a value from Option.none wins. withDecodingDefault always puts its
255+
// defaulting Link adjacent to the optional Union.
256+
for (const link of encoding) {
257+
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
258+
if (probe._tag !== "Success") continue
259+
if (Option.isSome(probe.value)) return { value: probe.value.value }
260+
}
261+
return undefined
224262
}
225263

226264
function union(ast: SchemaAST.Union): z.ZodTypeAny {

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

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

55
import { zod, ZodOverride } from "../../src/util/effect-zod"
@@ -669,4 +669,89 @@ describe("util.effect-zod", () => {
669669
expect(shape.properties.port.exclusiveMinimum).toBe(0)
670670
})
671671
})
672+
673+
describe("Schema.optionalWith defaults", () => {
674+
test("parsing undefined returns the default value", () => {
675+
const schema = zod(
676+
Schema.Struct({
677+
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
678+
}),
679+
)
680+
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
681+
expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
682+
})
683+
684+
test("parsing a real value returns that value (default does not fire)", () => {
685+
const schema = zod(
686+
Schema.Struct({
687+
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
688+
}),
689+
)
690+
expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
691+
})
692+
693+
test("default on a number field", () => {
694+
const schema = zod(
695+
Schema.Struct({
696+
count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
697+
}),
698+
)
699+
expect(schema.parse({})).toEqual({ count: 42 })
700+
expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
701+
})
702+
703+
test("multiple defaulted fields inside a struct", () => {
704+
const schema = zod(
705+
Schema.Struct({
706+
leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
707+
quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
708+
inner: Schema.String,
709+
}),
710+
)
711+
expect(schema.parse({ inner: "hi" })).toEqual({
712+
leader: "ctrl-x",
713+
quit: "ctrl-c",
714+
inner: "hi",
715+
})
716+
expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
717+
leader: "a",
718+
quit: "b",
719+
inner: "c",
720+
})
721+
})
722+
723+
test("JSON Schema output includes the default key", () => {
724+
const schema = zod(
725+
Schema.Struct({
726+
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
727+
}),
728+
)
729+
const shape = json(schema) as any
730+
expect(shape.properties.mode.default).toBe("ctrl-x")
731+
})
732+
733+
test("default referencing a computed value resolves when evaluated", () => {
734+
// Simulates `keybinds.ts` style of per-platform defaults: the default is
735+
// produced by an Effect that computes a value at decode time.
736+
const platform = "darwin"
737+
const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
738+
const schema = zod(
739+
Schema.Struct({
740+
command_palette: Schema.String.pipe(
741+
Schema.optional,
742+
Schema.withDecodingDefault(Effect.sync(() => fallback)),
743+
),
744+
}),
745+
)
746+
expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
747+
const shape = json(schema) as any
748+
expect(shape.properties.command_palette.default).toBe("cmd-k")
749+
})
750+
751+
test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
752+
const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
753+
expect(schema.parse({})).toEqual({})
754+
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
755+
})
756+
})
672757
})

0 commit comments

Comments
 (0)