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
|
// Declarations fall through to body(), not encoded(). User-level
|
||||||
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
||||||
// nodes, where we do apply the transform.
|
// 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 base = hasTransform ? encoded(ast) : body(ast)
|
||||||
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||||
const desc = SchemaAST.resolveDescription(ast)
|
const desc = SchemaAST.resolveDescription(ast)
|
||||||
@@ -217,10 +222,43 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
|||||||
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||||
if (ast._tag !== "Union") return fail(ast)
|
if (ast._tag !== "Union") return fail(ast)
|
||||||
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
||||||
if (items.length === 1) return walk(items[0]).optional()
|
const inner =
|
||||||
if (items.length > 1)
|
items.length === 1
|
||||||
return z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>]).optional()
|
? walk(items[0])
|
||||||
return z.undefined().optional()
|
: 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 {
|
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Schema, SchemaGetter } from "effect"
|
import { Effect, Schema, SchemaGetter } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
import { zod, ZodOverride } from "../../src/util/effect-zod"
|
||||||
@@ -669,4 +669,89 @@ describe("util.effect-zod", () => {
|
|||||||
expect(shape.properties.port.exclusiveMinimum).toBe(0)
|
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