From 227f943f896cd47d4c41f71f6e421aa1cf4305eb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Apr 2026 20:10:15 -0400 Subject: [PATCH] feat(effect-zod): translate Schema.withDecodingDefault to zod .default() Extends the walker so optional Effect Schema fields with a default value produce zod `.default(value)` instead of plain `.optional()`. Effect v4 exposes this via: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(v))) Shape in the AST: the outer node is still a Union of [X, Undefined] with `context.isOptional === true`, but `ast.encoding` now contains a Link whose `transformation.decode` Getter resolves `Option.none()` to `Option.some(defaultValue)`. The walker extracts that value by invoking the Getter with `Option.none()` at schema-build time. Walker changes (opt + extractDefault): - opt(ast) consolidates the existing three-branch optional construction into a single inner expression, then chooses .default(value) when extractDefault finds a default, falling back to .optional() otherwise. - extractDefault walks ast.encoding, probes each Link's decode Getter, returns the first concrete value. 7 new tests covering: - parsing undefined returns default - parsing a real value still wins - number field defaults - multiple defaulted fields + non-defaulted siblings - JSON Schema output includes the default key - default sourced from a computed Effect.sync (keybinds.ts pattern) - plain Schema.optional without default still emits .optional() (regression) Limitation (flagged in comments): the default is snapshotted at zod()-call time, not re-evaluated per parse. That matches the keybinds.ts per-platform use case since the platform is stable for the life of the process. Unblocks: config/keybinds.ts migration (every field has a default). All 1955 opencode tests pass. SDK byte-identical. --- packages/opencode/src/util/effect-zod.ts | 48 ++++++++-- .../opencode/test/util/effect-zod.test.ts | 87 ++++++++++++++++++- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 227a708442ea..cdcd99c9761d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -40,7 +40,12 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { // Declarations fall through to body(), not encoded(). User-level // Schema.decodeTo / Schema.transform attach encoding to non-Declaration // nodes, where we do apply the transform. - const hasTransform = ast.encoding?.length && ast._tag !== "Declaration" + // + // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` + // on the inner Zod rather than a transform wrapper — so optional ASTs whose + // encoding resolves a default from Option.none() route through body()/opt(). + const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base const desc = SchemaAST.resolveDescription(ast) @@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny { function opt(ast: SchemaAST.AST): z.ZodTypeAny { if (ast._tag !== "Union") return fail(ast) const items = ast.types.filter((item) => item._tag !== "Undefined") - if (items.length === 1) return walk(items[0]).optional() - if (items.length > 1) - return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]).optional() - return z.undefined().optional() + const inner = + items.length === 1 + ? walk(items[0]) + : items.length > 1 + ? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array]) + : z.undefined() + // Schema.withDecodingDefault attaches an encoding `Link` whose transformation + // decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke + // it to extract the default and emit `.default(...)` instead of `.optional()`. + const fallback = extractDefault(ast) + if (fallback !== undefined) return inner.default(fallback.value) + return inner.optional() +} + +type DecodeLink = { + readonly transformation: { + readonly decode: { + readonly run: ( + input: Option.Option, + options: SchemaAST.ParseOptions, + ) => Effect.Effect, unknown> + } + } +} + +function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined { + const encoding = (ast as { encoding?: ReadonlyArray }).encoding + if (!encoding?.length) return undefined + // Walk the chain of encoding Links in order; the first Getter that produces + // a value from Option.none wins. withDecodingDefault always puts its + // defaulting Link adjacent to the optional Union. + for (const link of encoding) { + const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {})) + if (probe._tag !== "Success") continue + if (Option.isSome(probe.value)) return { value: probe.value.value } + } + return undefined } function union(ast: SchemaAST.Union): z.ZodTypeAny { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 1d999e979d59..7ce43af5fbaf 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema, SchemaGetter } from "effect" +import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" import { zod, ZodOverride } from "../../src/util/effect-zod" @@ -669,4 +669,89 @@ describe("util.effect-zod", () => { expect(shape.properties.port.exclusiveMinimum).toBe(0) }) }) + + describe("Schema.optionalWith defaults", () => { + test("parsing undefined returns the default value", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + expect(schema.parse({})).toEqual({ mode: "ctrl-x" }) + expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" }) + }) + + test("parsing a real value returns that value (default does not fire)", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" }) + }) + + test("default on a number field", () => { + const schema = zod( + Schema.Struct({ + count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))), + }), + ) + expect(schema.parse({})).toEqual({ count: 42 }) + expect(schema.parse({ count: 7 })).toEqual({ count: 7 }) + }) + + test("multiple defaulted fields inside a struct", () => { + const schema = zod( + Schema.Struct({ + leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))), + inner: Schema.String, + }), + ) + expect(schema.parse({ inner: "hi" })).toEqual({ + leader: "ctrl-x", + quit: "ctrl-c", + inner: "hi", + }) + expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({ + leader: "a", + quit: "b", + inner: "c", + }) + }) + + test("JSON Schema output includes the default key", () => { + const schema = zod( + Schema.Struct({ + mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))), + }), + ) + const shape = json(schema) as any + expect(shape.properties.mode.default).toBe("ctrl-x") + }) + + test("default referencing a computed value resolves when evaluated", () => { + // Simulates `keybinds.ts` style of per-platform defaults: the default is + // produced by an Effect that computes a value at decode time. + const platform = "darwin" + const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k" + const schema = zod( + Schema.Struct({ + command_palette: Schema.String.pipe( + Schema.optional, + Schema.withDecodingDefault(Effect.sync(() => fallback)), + ), + }), + ) + expect(schema.parse({})).toEqual({ command_palette: "cmd-k" }) + const shape = json(schema) as any + expect(shape.properties.command_palette.default).toBe("cmd-k") + }) + + test("plain Schema.optional (no default) still emits .optional() (regression)", () => { + const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) })) + expect(schema.parse({})).toEqual({}) + expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) + }) + }) })