feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186)
This commit is contained in:
@@ -107,15 +107,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||||
|
// Pure record: { [k: string]: V }
|
||||||
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
||||||
const sig = ast.indexSignatures[0]
|
const sig = ast.indexSignatures[0]
|
||||||
if (sig.parameter._tag !== "String") return fail(ast)
|
if (sig.parameter._tag !== "String") return fail(ast)
|
||||||
return z.record(z.string(), walk(sig.type))
|
return z.record(z.string(), walk(sig.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ast.indexSignatures.length > 0) return fail(ast)
|
// Pure object with known fields and no index signatures.
|
||||||
|
if (ast.indexSignatures.length === 0) {
|
||||||
|
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||||
|
}
|
||||||
|
|
||||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
// Struct with a catchall (StructWithRest): known fields + index signature.
|
||||||
|
// Only supports a single string-keyed index signature; multi-signature or
|
||||||
|
// symbol/number keys fall through to fail.
|
||||||
|
if (ast.indexSignatures.length !== 1) return fail(ast)
|
||||||
|
const sig = ast.indexSignatures[0]
|
||||||
|
if (sig.parameter._tag !== "String") return fail(ast)
|
||||||
|
return z
|
||||||
|
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
|
||||||
|
.catchall(walk(sig.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||||
|
|||||||
@@ -263,4 +263,73 @@ describe("util.effect-zod", () => {
|
|||||||
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("StructWithRest / catchall", () => {
|
||||||
|
test("struct with a string-keyed record rest parses known AND extra keys", () => {
|
||||||
|
const schema = zod(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
apiKey: Schema.optional(Schema.String),
|
||||||
|
baseURL: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known fields come through as declared
|
||||||
|
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
|
||||||
|
|
||||||
|
// Extra keys are preserved (catchall)
|
||||||
|
expect(
|
||||||
|
schema.parse({
|
||||||
|
apiKey: "sk-x",
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
customField: "anything",
|
||||||
|
nested: { foo: 1 },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
apiKey: "sk-x",
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
customField: "anything",
|
||||||
|
nested: { foo: 1 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("catchall value type constrains the extras", () => {
|
||||||
|
const schema = zod(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
count: Schema.Number,
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Number)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Known field + numeric extras
|
||||||
|
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
|
||||||
|
|
||||||
|
// Non-numeric extra is rejected
|
||||||
|
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("JSON schema output marks additionalProperties appropriately", () => {
|
||||||
|
const schema = zod(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const shape = json(schema) as { additionalProperties?: unknown }
|
||||||
|
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
|
||||||
|
expect(shape.additionalProperties).not.toBe(false)
|
||||||
|
expect(shape.additionalProperties).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
|
||||||
|
const schema = zod(Schema.Struct({ id: Schema.String }))
|
||||||
|
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user