summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-17 19:10:34 -0400
committeropencode <[email protected]>2026-04-18 00:29:26 +0000
commit7b98f544ff6b58f8dde9421bd1e7bf3e8c395d4f (patch)
treefdfb36898dc71cae582ea42806d857afa63815f5
parentb5aba5807cfbcafc57ffd488cbcb0148f8f1f4d6 (diff)
downloadopencode-7b98f544ff6b58f8dde9421bd1e7bf3e8c395d4f.tar.gz
opencode-7b98f544ff6b58f8dde9421bd1e7bf3e8c395d4f.zip
feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186)
-rw-r--r--packages/opencode/src/util/effect-zod.ts16
-rw-r--r--packages/opencode/test/util/effect-zod.test.ts69
2 files changed, 83 insertions, 2 deletions
diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts
index 771795ba6..22c6eda42 100644
--- a/packages/opencode/src/util/effect-zod.ts
+++ b/packages/opencode/src/util/effect-zod.ts
@@ -107,15 +107,27 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
}
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
+ // Pure record: { [k: string]: V }
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
const sig = ast.indexSignatures[0]
if (sig.parameter._tag !== "String") return fail(ast)
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 {
diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts
index ba67a60e6..89234e726 100644
--- a/packages/opencode/test/util/effect-zod.test.ts
+++ b/packages/opencode/test/util/effect-zod.test.ts
@@ -263,4 +263,73 @@ describe("util.effect-zod", () => {
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" })
+ })
+ })
})