diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 9b20eb1a0..073f93d0a 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -275,6 +275,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); @@ -302,7 +318,11 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { ); }); }); - this.emitLine(hasOptionalConstructor ? "}));" : "});"); + this.emitLine( + "})", + this.objectUnknownKeySuffix(t), + hasOptionalConstructor ? ");" : ";", + ); if (!this._options.justSchema) { this.emitLine( "export type ", @@ -368,7 +388,7 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { }, ); }); - this.emitLine("})"); + this.emitLine("})", this.objectUnknownKeySuffix(t)); }); this.emitLine(");"); } @@ -444,8 +464,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 efe525f81..4dad7b935 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/language.ts @@ -54,6 +54,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())"); +});