mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207)
This commit is contained in:
@@ -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<z.ZodTypeAny>]).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.ZodTypeAny>])
|
||||
: 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<unknown>,
|
||||
options: SchemaAST.ParseOptions,
|
||||
) => Effect.Effect<Option.Option<unknown>, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
|
||||
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).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 {
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user