diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index d9d62c617..7e08d912a 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -223,6 +223,22 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { return this._recursiveTypeRefs.has(t.typeRef); } + protected objectUnknownKeySuffix(t: ObjectType): Sourcelike { + const additionalProperties = t.getAdditionalProperties(); + if (additionalProperties === undefined) { + return ".strict()"; + } + if (additionalProperties.kind === "any") { + return ".passthrough()"; + } + + return [ + ".catchall(", + this.typeMapTypeFor(additionalProperties, false), + ")", + ]; + } + protected emitObject(name: Name, t: ObjectType): void { if (this.isRecursive(t)) { this.emitLazyObject(name, t); @@ -241,7 +257,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { ); }); }); - this.emitLine("});"); + this.emitLine("})", this.objectUnknownKeySuffix(t), ";"); if (!this._options.justSchema) { this.emitLine( "export type ", @@ -307,7 +323,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { }, ); }); - this.emitLine("})"); + this.emitLine("})", this.objectUnknownKeySuffix(t)); }); this.emitLine(");"); } @@ -383,8 +399,9 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { } } - // Finally return the reference to a class as that will need to be defined (where objects, maps, unions, intersections and arrays do not) - if (type instanceof ClassType) { + // Finally return references to named classes and full objects as those + // need to be defined. Maps, unions, intersections, and arrays do not. + if (type instanceof ClassType || type.kind === "object") { typeRefs.push(type.typeRef); } } diff --git a/packages/quicktype-core/src/language/TypeScriptZod/language.ts b/packages/quicktype-core/src/language/TypeScriptZod/language.ts index 63b9abab5..72e8b7614 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/language.ts @@ -51,6 +51,10 @@ export class TypeScriptZodTargetLanguage extends TargetLanguage< return true; } + public get supportsFullObjectType(): boolean { + return true; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/test/unit/typescript-zod-additional-properties.test.ts b/test/unit/typescript-zod-additional-properties.test.ts new file mode 100644 index 000000000..e0a9336c6 --- /dev/null +++ b/test/unit/typescript-zod-additional-properties.test.ts @@ -0,0 +1,40 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { expect, test } from "vitest"; + +test("TypeScript Zod preserves JSON Schema additionalProperties semantics", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify({ + type: "object", + properties: { + strict: { + type: "object", + properties: { value: { type: "string" } }, + additionalProperties: false, + }, + loose: { + type: "object", + properties: { value: { type: "string" } }, + additionalProperties: true, + }, + typed: { + type: "object", + properties: { value: { type: "string" } }, + additionalProperties: { type: "boolean" }, + }, + }, + required: ["strict", "loose", "typed"], + additionalProperties: false, + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript-zod" }); + const output = result.lines.join("\n"); + + expect(output).toContain(".strict()"); + expect(output).toContain(".passthrough()"); + expect(output).toContain(".catchall(z.boolean())"); +});