From 90b40be35e1178be3bc7ce78463d00d0a50657a9 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sat, 5 Sep 2026 19:17:29 -0700 Subject: [PATCH 1/3] fix(typescript): add undefined to index signature for optional properties An interface with both properties and typed additionalProperties emitted an index signature that omitted `undefined`, so optional properties of type `T | undefined` failed `tsc --strict` with TS2411. Also dedupe the index signature members and drop the union when additionalProperties is `any`. Fixes #3123 Co-Authored-By: Claude Fable 5.1 --- .../language/TypeScriptFlow/FlowRenderer.ts | 4 ++ .../TypeScriptFlowBaseRenderer.ts | 50 ++++++++++++++++--- test/fixtures/typescript/tsconfig.json | 2 +- ...ed-additional-properties.1.fail.union.json | 4 ++ .../schema/mixed-additional-properties.1.json | 5 ++ .../schema/mixed-additional-properties.2.json | 3 ++ .../schema/mixed-additional-properties.schema | 11 ++++ test/languages.ts | 1 + 8 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 test/inputs/schema/mixed-additional-properties.1.fail.union.json create mode 100644 test/inputs/schema/mixed-additional-properties.1.json create mode 100644 test/inputs/schema/mixed-additional-properties.2.json create mode 100644 test/inputs/schema/mixed-additional-properties.schema diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/FlowRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/FlowRenderer.ts index 9858284acb..ba419f178e 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/FlowRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/FlowRenderer.ts @@ -13,6 +13,10 @@ export class FlowRenderer extends TypeScriptFlowBaseRenderer { return this._tsFlowOptions.preferUnknown ? "mixed" : "any"; } + protected get undefinedType(): string { + return "void"; + } + protected forbiddenNamesForGlobalNamespace(): string[] { return [ "Class", diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index 179e118c20..c215de4483 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -96,6 +96,13 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { return singleWord([parenIfNeeded(itemType), "[]"]); } + /** Whether a union is spelled out as `a | b` instead of by name. */ + private inlinesUnion(u: UnionType): boolean { + return ( + !this._tsFlowOptions.declareUnions || nullableFromUnion(u) !== null + ); + } + protected sourceFor(t: Type): MultiWord { const emptyObjectType = this.emptyObjectTypeFor(t); if (emptyObjectType !== undefined) { @@ -134,10 +141,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { ]), (_enumType) => panic("We handled this above"), (unionType) => { - if ( - !this._tsFlowOptions.declareUnions || - nullableFromUnion(unionType) !== null - ) { + if (this.inlinesUnion(unionType)) { const children = Array.from(unionType.getChildren()).map( (c) => parenIfNeeded(this.sourceFor(c)), ); @@ -162,6 +166,11 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { * `prefer-unknown` option, plain `any` without it. */ protected abstract anyType(): string; + /** The type spelling for a missing optional property. */ + protected get undefinedType(): string { + return "undefined"; + } + protected abstract emitEnum(e: EnumType, enumName: Name): void; protected abstract emitClassBlock(c: ClassType, className: Name): void; @@ -188,10 +197,35 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { const additionalProperties = c.getAdditionalProperties(); if (additionalProperties) { - const indexTypes = [ - additionalProperties, - ...Array.from(c.getProperties().values(), (p) => p.type), - ].map((t) => this.sourceFor(t).source); + const properties = Array.from(c.getProperties().values()); + let indexTypes: Sourcelike[]; + if (additionalProperties.kind === "any") { + indexTypes = [this.anyType()]; + } else { + // Inlined unions render as their members, so collect + // those members to avoid repeating them. + const types = new Set(); + const addType = (t: Type): void => { + if (t instanceof UnionType && this.inlinesUnion(t)) { + for (const child of t.getChildren()) { + addType(child); + } + } else { + types.add(t); + } + }; + + addType(additionalProperties); + for (const p of properties) { + addType(p.type); + } + + indexTypes = Array.from(types, (t) => this.sourceFor(t).source); + if (properties.some((p) => p.isOptional)) { + indexTypes.push(this.undefinedType); + } + } + this.emitTable([ [ "[property: string]", diff --git a/test/fixtures/typescript/tsconfig.json b/test/fixtures/typescript/tsconfig.json index 6bcd3937d1..d5777e9fbb 100644 --- a/test/fixtures/typescript/tsconfig.json +++ b/test/fixtures/typescript/tsconfig.json @@ -3,7 +3,7 @@ "target": "es6", "noEmit": true, "noImplicitAny": true, - "strictNullChecks": false + "strictNullChecks": true }, "files": ["main.ts"] } diff --git a/test/inputs/schema/mixed-additional-properties.1.fail.union.json b/test/inputs/schema/mixed-additional-properties.1.fail.union.json new file mode 100644 index 0000000000..918763aed7 --- /dev/null +++ b/test/inputs/schema/mixed-additional-properties.1.fail.union.json @@ -0,0 +1,4 @@ +{ + "id": "abc", + "score": true +} diff --git a/test/inputs/schema/mixed-additional-properties.1.json b/test/inputs/schema/mixed-additional-properties.1.json new file mode 100644 index 0000000000..09b2d2e561 --- /dev/null +++ b/test/inputs/schema/mixed-additional-properties.1.json @@ -0,0 +1,5 @@ +{ + "id": "abc", + "score": 1, + "count": 2 +} diff --git a/test/inputs/schema/mixed-additional-properties.2.json b/test/inputs/schema/mixed-additional-properties.2.json new file mode 100644 index 0000000000..d2ae1507a0 --- /dev/null +++ b/test/inputs/schema/mixed-additional-properties.2.json @@ -0,0 +1,3 @@ +{ + "score": 3.5 +} diff --git a/test/inputs/schema/mixed-additional-properties.schema b/test/inputs/schema/mixed-additional-properties.schema new file mode 100644 index 0000000000..63ae66d34a --- /dev/null +++ b/test/inputs/schema/mixed-additional-properties.schema @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": { + "type": "number" + } +} diff --git a/test/languages.ts b/test/languages.ts index 587adaeecb..972f6e4a05 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1348,6 +1348,7 @@ export const KotlinLanguage: Language = { // instead of rejecting it. "nested-intersection-union.schema", "class-with-additional.schema", + "mixed-additional-properties.schema", ...skipsMapValueValidation, // IllegalArgumentException // KlaxonException: Need to extract inside From d316ce63533259c102e50d8e38e90723fb9086dc Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sat, 5 Sep 2026 19:54:02 -0700 Subject: [PATCH 2/3] test(php): skip mixed-additional-properties.schema PHP emits no class for a top-level object with additionalProperties, so the driver echoes the input and the negative sample cannot fail. Co-Authored-By: Claude Fable 5.1 --- test/languages.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 972f6e4a05..6fd162e95c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1716,6 +1716,8 @@ export const PHPLanguage: Language = { "recursive-union-flattening.schema", // The driver does not support top-level arrays. "issue2680-top-level-array.schema", + // No class is emitted for a top-level object with additionalProperties. + "mixed-additional-properties.schema", ], rendererOptions: {}, quickTestRendererOptions: [], From 62a55840334f43ab12013c51692e4dd47ac685cb Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sat, 5 Sep 2026 19:58:49 -0700 Subject: [PATCH 3/3] test: drop redundant mixed-additional-properties fixture class-with-additional.schema already covers an optional property next to typed additionalProperties, and class-map-union.schema covers the dedupe and any cases. With strictNullChecks on they catch TS2411. Co-Authored-By: Claude Fable 5.1 --- .../mixed-additional-properties.1.fail.union.json | 4 ---- test/inputs/schema/mixed-additional-properties.1.json | 5 ----- test/inputs/schema/mixed-additional-properties.2.json | 3 --- test/inputs/schema/mixed-additional-properties.schema | 11 ----------- test/languages.ts | 3 --- 5 files changed, 26 deletions(-) delete mode 100644 test/inputs/schema/mixed-additional-properties.1.fail.union.json delete mode 100644 test/inputs/schema/mixed-additional-properties.1.json delete mode 100644 test/inputs/schema/mixed-additional-properties.2.json delete mode 100644 test/inputs/schema/mixed-additional-properties.schema diff --git a/test/inputs/schema/mixed-additional-properties.1.fail.union.json b/test/inputs/schema/mixed-additional-properties.1.fail.union.json deleted file mode 100644 index 918763aed7..0000000000 --- a/test/inputs/schema/mixed-additional-properties.1.fail.union.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "id": "abc", - "score": true -} diff --git a/test/inputs/schema/mixed-additional-properties.1.json b/test/inputs/schema/mixed-additional-properties.1.json deleted file mode 100644 index 09b2d2e561..0000000000 --- a/test/inputs/schema/mixed-additional-properties.1.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "abc", - "score": 1, - "count": 2 -} diff --git a/test/inputs/schema/mixed-additional-properties.2.json b/test/inputs/schema/mixed-additional-properties.2.json deleted file mode 100644 index d2ae1507a0..0000000000 --- a/test/inputs/schema/mixed-additional-properties.2.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "score": 3.5 -} diff --git a/test/inputs/schema/mixed-additional-properties.schema b/test/inputs/schema/mixed-additional-properties.schema deleted file mode 100644 index 63ae66d34a..0000000000 --- a/test/inputs/schema/mixed-additional-properties.schema +++ /dev/null @@ -1,11 +0,0 @@ -{ - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "additionalProperties": { - "type": "number" - } -} diff --git a/test/languages.ts b/test/languages.ts index 6fd162e95c..587adaeecb 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1348,7 +1348,6 @@ export const KotlinLanguage: Language = { // instead of rejecting it. "nested-intersection-union.schema", "class-with-additional.schema", - "mixed-additional-properties.schema", ...skipsMapValueValidation, // IllegalArgumentException // KlaxonException: Need to extract inside @@ -1716,8 +1715,6 @@ export const PHPLanguage: Language = { "recursive-union-flattening.schema", // The driver does not support top-level arrays. "issue2680-top-level-array.schema", - // No class is emitted for a top-level object with additionalProperties. - "mixed-additional-properties.schema", ], rendererOptions: {}, quickTestRendererOptions: [],