From 3678ac36c66c58a482cec4c68427a99c643682e5 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 18:24:00 -0400 Subject: [PATCH 1/6] fix(core): preserve distinct object alternatives in oneOf/anyOf unions (#1266) Co-Authored-By: gpt-5.6-sol via pi --- packages/quicktype-core/src/Run.ts | 4 + packages/quicktype-core/src/TargetLanguage.ts | 4 + packages/quicktype-core/src/Type/Type.ts | 81 +++++++++++++------ packages/quicktype-core/src/UnifyClasses.ts | 39 +++++++++ packages/quicktype-core/src/UnionBuilder.ts | 59 ++++++++++++-- .../src/attributes/UnionMembers.ts | 61 ++++++++++++++ .../src/input/JSONSchemaInput.ts | 32 ++++++++ .../src/language/JSONSchema/language.ts | 4 + .../src/language/JavaScript/language.ts | 4 + .../TypeScriptFlowBaseRenderer.ts | 25 +++--- .../TypeScriptFlow/TypeScriptRenderer.ts | 62 +++++++++++++- .../src/language/TypeScriptFlow/language.ts | 8 ++ .../src/rewrites/FlattenUnions.ts | 2 + test/inputs/schema/one-of-objects.1.json | 1 + .../schema/one-of-objects.2.fail.one-of.json | 1 + test/inputs/schema/one-of-objects.schema | 25 ++++++ test/languages.ts | 21 ++++- test/unit/schema-object-unions.test.ts | 49 +++++++++++ 18 files changed, 435 insertions(+), 47 deletions(-) create mode 100644 packages/quicktype-core/src/attributes/UnionMembers.ts create mode 100644 test/inputs/schema/one-of-objects.1.json create mode 100644 test/inputs/schema/one-of-objects.2.fail.one-of.json create mode 100644 test/inputs/schema/one-of-objects.schema create mode 100644 test/unit/schema-object-unions.test.ts diff --git a/packages/quicktype-core/src/Run.ts b/packages/quicktype-core/src/Run.ts index 898640aff5..2253823b9a 100644 --- a/packages/quicktype-core/src/Run.ts +++ b/packages/quicktype-core/src/Run.ts @@ -342,6 +342,7 @@ class Run implements RunContext { stringTypeMapping, conflateNumbers, true, + targetLanguage.supportsUnionsWithMultipleObjectTypes, debugPrintReconstitution, ); }); @@ -372,6 +373,7 @@ class Run implements RunContext { stringTypeMapping, conflateNumbers, false, + targetLanguage.supportsUnionsWithMultipleObjectTypes, debugPrintReconstitution, ); }); @@ -436,6 +438,7 @@ class Run implements RunContext { stringTypeMapping, conflateNumbers, false, + targetLanguage.supportsUnionsWithMultipleObjectTypes, debugPrintReconstitution, ); }); @@ -485,6 +488,7 @@ class Run implements RunContext { stringTypeMapping, conflateNumbers, false, + targetLanguage.supportsUnionsWithMultipleObjectTypes, debugPrintReconstitution, ); }); diff --git a/packages/quicktype-core/src/TargetLanguage.ts b/packages/quicktype-core/src/TargetLanguage.ts index f0617b7390..f916fa9d6b 100644 --- a/packages/quicktype-core/src/TargetLanguage.ts +++ b/packages/quicktype-core/src/TargetLanguage.ts @@ -115,6 +115,10 @@ export abstract class TargetLanguage< return false; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return false; + } + public needsTransformerForType(_t: Type): boolean { return false; } diff --git a/packages/quicktype-core/src/Type/Type.ts b/packages/quicktype-core/src/Type/Type.ts index f888f4033f..f54eb07701 100644 --- a/packages/quicktype-core/src/Type/Type.ts +++ b/packages/quicktype-core/src/Type/Type.ts @@ -6,7 +6,6 @@ import { hashCodeOf, iterableEvery, iterableFind, - iterableSome, mapFilter, mapMap, mapSome, @@ -20,6 +19,7 @@ import { } from "collection-utils"; import type { TypeAttributes } from "../attributes/TypeAttributes.js"; +import { isUnionMemberDistinct } from "../attributes/UnionMembers.js"; import { type TypeNames, namesTypeAttributeKind, @@ -780,25 +780,45 @@ export function setOperationCasesEqual( const ma = toReadonlySet(typesA); const mb = toReadonlySet(typesB); if (ma.size !== mb.size) return false; - return iterableEvery(ma, (ta) => { - const tb = iterableFind(mb, (t) => t.kind === ta.kind); - if (tb !== undefined && membersEqual(ta, tb)) return true; - if (conflateNumbers) { - if ( - ta.kind === "integer" && - iterableSome(mb, (t) => t.kind === "double") - ) - return true; - if ( - ta.kind === "double" && - iterableSome(mb, (t) => t.kind === "integer") - ) - return true; + function kindForComparison(t: Type): TypeKind { + if (conflateNumbers && (t.kind === "integer" || t.kind === "double")) { + return "double"; } - return false; - }); + return t.kind; + } + + function groupByKind(types: ReadonlySet): Map { + const groups = new Map(); + for (const t of types) { + const kind = kindForComparison(t); + const group = groups.get(kind); + if (group === undefined) { + groups.set(kind, [t]); + } else { + group.push(t); + } + } + + return groups; + } + + const groupsA = groupByKind(ma); + const groupsB = groupByKind(mb); + if (groupsA.size !== groupsB.size) return false; + for (const [kind, groupA] of groupsA) { + const groupB = groupsB.get(kind); + if (groupB === undefined || groupA.length !== groupB.length) { + return false; + } + + for (let i = 0; i < groupA.length; i++) { + if (!membersEqual(groupA[i], groupB[i])) return false; + } + } + + return true; } export function setOperationTypeIdentity( @@ -861,7 +881,6 @@ export abstract class SetOperationType extends Type { } public getNonAttributeChildren(): Set { - // FIXME: We're assuming no two members of the same kind. return setSortBy(this.members, (t) => t.kind); } @@ -980,18 +999,30 @@ export class UnionType extends SetOperationType { const members = this.members; if (members.size <= 1) return false; const kinds = setMap(members, (t) => t.kind); - if (kinds.size < members.size) return false; if (kinds.has("union") || kinds.has("intersection")) return false; if (kinds.has("none") || kinds.has("any")) return false; if (kinds.has("string") && kinds.has("enum")) return false; - let numObjectTypes = 0; - if (kinds.has("class")) numObjectTypes += 1; - if (kinds.has("map")) numObjectTypes += 1; - if (kinds.has("object")) numObjectTypes += 1; - if (numObjectTypes > 1) return false; + const objectMembers = setFilter( + members, + (member) => member instanceof ObjectType, + ); + const nonObjectMembers = setFilter( + members, + (member) => !(member instanceof ObjectType), + ); + const nonObjectKinds = setMap( + nonObjectMembers, + (member) => member.kind, + ); + if (nonObjectKinds.size < nonObjectMembers.size) return false; - return true; + return ( + objectMembers.size <= 1 || + iterableEvery(objectMembers, (member) => + isUnionMemberDistinct(member.getAttributes()), + ) + ); } public reconstitute( diff --git a/packages/quicktype-core/src/UnifyClasses.ts b/packages/quicktype-core/src/UnifyClasses.ts index b6a5a6efb9..35a01c9c1f 100644 --- a/packages/quicktype-core/src/UnifyClasses.ts +++ b/packages/quicktype-core/src/UnifyClasses.ts @@ -5,6 +5,10 @@ import { combineTypeAttributes, emptyTypeAttributes, } from "./attributes/TypeAttributes.js"; +import { + isUnionMemberDistinct, + unionMemberDistinctAttributes, +} from "./attributes/UnionMembers.js"; import type { BaseGraphRewriteBuilder, GraphRewriteBuilder, @@ -125,11 +129,45 @@ export class UnifyUnionBuilder extends UnionBuilder< typeBuilder: BaseGraphRewriteBuilder, private readonly _makeObjectTypes: boolean, private readonly _makeClassesFixed: boolean, + private readonly _preserveDistinctObjects: boolean, private readonly _unifyTypes: (typesToUnify: TypeRef[]) => TypeRef, ) { super(typeBuilder); } + protected objectsAreDistinct( + objectRefs: TypeRef[], + attributes: TypeAttributes, + ): boolean { + return ( + this._preserveDistinctObjects && + objectRefs.length > 1 && + isUnionMemberDistinct(attributes) && + // Renderers cannot represent properties that are exceptions to a + // typed additionalProperties index signature. + objectRefs.every((ref) => { + const object = assertIsObject( + derefTypeRef(ref, this.typeBuilder), + ); + const additional = object.getAdditionalProperties(); + return ( + object.getProperties().size === 0 || + additional === undefined || + additional.kind === "any" + ); + }) + ); + } + + protected makeDistinctObjects(objectRefs: TypeRef[]): TypeRef[] { + return objectRefs.map((ref) => + this.typeBuilder.reconstituteTypeRef( + ref, + unionMemberDistinctAttributes, + ), + ); + } + protected makeObject( objectRefs: TypeRef[], typeAttributes: TypeAttributes, @@ -249,6 +287,7 @@ export function unionBuilderForUnification( typeBuilder, makeObjectTypes, makeClassesFixed, + false, (trefs) => unifyTypes( new Set(trefs.map((tref) => derefTypeRef(tref, typeBuilder))), diff --git a/packages/quicktype-core/src/UnionBuilder.ts b/packages/quicktype-core/src/UnionBuilder.ts index 611a147858..5aaf09e735 100644 --- a/packages/quicktype-core/src/UnionBuilder.ts +++ b/packages/quicktype-core/src/UnionBuilder.ts @@ -503,6 +503,17 @@ export abstract class UnionBuilder< forwardingRef: TypeRef | undefined, ): TypeRef; + protected objectsAreDistinct( + _objects: TObjectData, + _attributes: TypeAttributes, + ): boolean { + return false; + } + + protected makeDistinctObjects(_objects: TObjectData): TypeRef[] { + return panic("This union builder cannot make distinct object types"); + } + private makeTypeOfKind( typeProvider: UnionTypeProvider, kind: TypeKind, @@ -574,6 +585,28 @@ export abstract class UnionBuilder< // right now, it's just a very bad way of surfacing that error. if (kinds.size === 1) { const [[kind, memberAttributes]] = Array.from(kinds); + if ( + kind === "object" && + this.objectsAreDistinct( + typeProvider.objectData, + memberAttributes, + ) + ) { + const objects = this.makeDistinctObjects( + typeProvider.objectData, + ); + const objectSet = new Set(objects); + assert( + objectSet.size > 1, + "Distinct object union must have multiple members", + ); + return this.typeBuilder.getUnionType( + typeAttributes, + objectSet, + forwardingRef, + ); + } + const allAttributes = combineTypeAttributes( "union", typeAttributes, @@ -598,14 +631,26 @@ export abstract class UnionBuilder< const types: TypeRef[] = []; for (const [kind, memberAttributes] of kinds) { - types.push( - this.makeTypeOfKind( - typeProvider, - kind, + if ( + kind === "object" && + this.objectsAreDistinct( + typeProvider.objectData, memberAttributes, - undefined, - ), - ); + ) + ) { + types.push( + ...this.makeDistinctObjects(typeProvider.objectData), + ); + } else { + types.push( + this.makeTypeOfKind( + typeProvider, + kind, + memberAttributes, + undefined, + ), + ); + } } const typesSet = new Set(types); diff --git a/packages/quicktype-core/src/attributes/UnionMembers.ts b/packages/quicktype-core/src/attributes/UnionMembers.ts new file mode 100644 index 0000000000..d96dfabfd1 --- /dev/null +++ b/packages/quicktype-core/src/attributes/UnionMembers.ts @@ -0,0 +1,61 @@ +import { TypeAttributeKind, type TypeAttributes } from "./TypeAttributes.js"; + +class UnionMemberDistinctTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("unionMemberDistinct"); + } + + public combine(values: boolean[]): boolean { + return values.some(Boolean); + } + + public intersect(values: boolean[]): boolean { + return values.some(Boolean); + } + + public makeInferred(value: boolean): boolean { + return value; + } +} + +class UnionIsExclusiveTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("unionIsExclusive"); + } + + public combine(values: boolean[]): boolean { + return values.some(Boolean); + } + + public intersect(values: boolean[]): boolean { + return values.some(Boolean); + } + + public makeInferred(value: boolean): boolean { + return value; + } +} + +export const unionMemberDistinctTypeAttributeKind = + new UnionMemberDistinctTypeAttributeKind(); +const unionIsExclusiveTypeAttributeKind = + new UnionIsExclusiveTypeAttributeKind(); + +export const unionMemberDistinctAttributes: TypeAttributes = + unionMemberDistinctTypeAttributeKind.makeAttributes(true); +export const unionIsExclusiveAttributes: TypeAttributes = + unionIsExclusiveTypeAttributeKind.makeAttributes(true); + +export function isUnionMemberDistinct(attributes: TypeAttributes): boolean { + return ( + unionMemberDistinctTypeAttributeKind.tryGetInAttributes(attributes) === + true + ); +} + +export function isUnionExclusive(attributes: TypeAttributes): boolean { + return ( + unionIsExclusiveTypeAttributeKind.tryGetInAttributes(attributes) === + true + ); +} diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index efd2ccbef9..159b10f99f 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -45,6 +45,10 @@ import { singularizeTypeNames, } from "../attributes/TypeNames.js"; import { uriSchemaAttributesProducer } from "../attributes/URIAttributes.js"; +import { + unionIsExclusiveAttributes, + unionMemberDistinctAttributes, +} from "../attributes/UnionMembers.js"; import { messageAssert, messageError } from "../Messages.js"; import type { RunContext } from "../Run.js"; import { @@ -1245,6 +1249,34 @@ async function addTypesInSchema( ); } + // A set operation nested under allOf is part of an intersection, + // not a standalone union of alternatives. + const isStandaloneSetOperation = + !needUnion && + !loc.canonicalRef.path.some( + (element) => keyOrIndex(element) === "allOf", + ) && + schema.$ref === undefined && + schema.allOf === undefined && + (kind === "oneOf" + ? schema.anyOf === undefined + : schema.oneOf === undefined); + if (isStandaloneSetOperation) { + for (const typeRef of typeRefs) { + typeBuilder.addAttributes( + typeRef, + unionMemberDistinctAttributes, + ); + } + if (kind === "oneOf") { + unionAttributes = combineTypeAttributes( + "union", + unionAttributes, + unionIsExclusiveAttributes, + ); + } + } + const unionType = typeBuilder.getUniqueUnionType( unionAttributes, undefined, diff --git a/packages/quicktype-core/src/language/JSONSchema/language.ts b/packages/quicktype-core/src/language/JSONSchema/language.ts index f0488ae8fc..d8c25f10b6 100644 --- a/packages/quicktype-core/src/language/JSONSchema/language.ts +++ b/packages/quicktype-core/src/language/JSONSchema/language.ts @@ -43,6 +43,10 @@ export class JSONSchemaTargetLanguage extends TargetLanguage< return true; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return true; + } + protected makeRenderer( renderContext: RenderContext, _untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/JavaScript/language.ts b/packages/quicktype-core/src/language/JavaScript/language.ts index 89e7452ebb..30b3a8611d 100644 --- a/packages/quicktype-core/src/language/JavaScript/language.ts +++ b/packages/quicktype-core/src/language/JavaScript/language.ts @@ -84,6 +84,10 @@ export class JavaScriptTargetLanguage extends TargetLanguage< return true; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return true; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts index efcdbb2cee..279b11b38b 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptFlowBaseRenderer.ts @@ -64,6 +64,13 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { return singleWord([parenIfNeeded(itemType), "[]"]); } + protected sourceForUnionMembers(unionType: UnionType): MultiWord { + const children = Array.from(unionType.getChildren()).map((child) => + parenIfNeeded(this.sourceFor(child)), + ); + return multiWord(" | ", ...children); + } + protected sourceFor(t: Type): MultiWord { if ( this._tsFlowOptions.preferConstValues && @@ -101,10 +108,7 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { !this._tsFlowOptions.declareUnions || nullableFromUnion(unionType) !== null ) { - const children = Array.from(unionType.getChildren()).map( - (c) => parenIfNeeded(this.sourceFor(c)), - ); - return multiWord(" | ", ...children); + return this.sourceForUnionMembers(unionType); } return singleWord(this.nameForNamedType(unionType)); @@ -168,18 +172,17 @@ export abstract class TypeScriptFlowBaseRenderer extends JavaScriptRenderer { } protected emitUnion(u: UnionType, unionName: Name): void { - if (!this._tsFlowOptions.declareUnions) { + const isTopLevel = Array.from(this.topLevels.values()).includes(u); + if ( + !this._tsFlowOptions.declareUnions && + !(this._tsFlowOptions.justTypes && isTopLevel) + ) { return; } this.emitDescription(this.descriptionForType(u)); - const children = multiWord( - " | ", - ...Array.from(u.getChildren()).map((c) => - parenIfNeeded(this.sourceFor(c)), - ), - ); + const children = this.sourceForUnionMembers(u); this.emitLine("export type ", unionName, " = ", children.source, ";"); } diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts index f1c63f6bf3..c98d33f4ac 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/TypeScriptRenderer.ts @@ -1,14 +1,23 @@ import { minMaxItemsForType } from "../../attributes/Constraints.js"; +import { isUnionExclusive } from "../../attributes/UnionMembers.js"; import type { Name } from "../../Naming.js"; import { type MultiWord, type Sourcelike, modifySource, + multiWord, parenIfNeeded, singleWord, } from "../../Source.js"; import { camelCase, utf16StringEscape } from "../../support/Strings.js"; -import type { ArrayType, ClassType, EnumType, Type } from "../../Type/index.js"; +import { + type ArrayType, + type ClassType, + type EnumType, + ObjectType, + type Type, + type UnionType, +} from "../../Type/index.js"; import { isNamedType } from "../../Type/TypeUtils.js"; import type { JavaScriptTypeAnnotations } from "../JavaScript/index.js"; @@ -21,6 +30,57 @@ import { tsFlowTypeAnnotations } from "./utils.js"; const maxSpelledOutMinItems = 16; export class TypeScriptRenderer extends TypeScriptFlowBaseRenderer { + protected sourceForUnionMembers(unionType: UnionType): MultiWord { + if (!isUnionExclusive(unionType.getAttributes())) { + return super.sourceForUnionMembers(unionType); + } + + const members = Array.from(unionType.members); + if ( + members.length < 2 || + !members.every((member) => member instanceof ObjectType) + ) { + return super.sourceForUnionMembers(unionType); + } + + // TypeScript's excess-property checks allow an object literal to mix + // properties from different union members. Exclude sibling keys so a + // oneOf remains exclusive in the generated type. + const propertyNames = new Set(); + for (const member of members as ObjectType[]) { + for (const name of member.getProperties().keys()) { + propertyNames.add(name); + } + } + + const memberSources = (members as ObjectType[]).map((member) => { + const excludedProperties: Sourcelike[] = []; + for (const name of propertyNames) { + if (member.getProperties().has(name)) continue; + excludedProperties.push( + `"${utf16StringEscape(name)}"?: never; `, + ); + } + + if (excludedProperties.length === 0) { + return singleWord(this.sourceFor(member).source); + } + + return singleWord([ + "(", + this.sourceFor(member).source, + " & { ", + excludedProperties, + "})", + ]); + }); + + return multiWord( + " | ", + ...memberSources.map((source) => source.source), + ); + } + protected anyType(): string { return this._tsFlowOptions.preferUnknown ? "unknown" : "any"; } diff --git a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts index 0c930a99e6..9c0b8de892 100644 --- a/packages/quicktype-core/src/language/TypeScriptFlow/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptFlow/language.ts @@ -90,6 +90,10 @@ export class TypeScriptTargetLanguage extends TargetLanguage< return true; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return true; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, @@ -140,6 +144,10 @@ export class FlowTargetLanguage extends TargetLanguage< return true; } + public get supportsUnionsWithMultipleObjectTypes(): boolean { + return true; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/rewrites/FlattenUnions.ts b/packages/quicktype-core/src/rewrites/FlattenUnions.ts index aab4d85119..d30301dbc7 100644 --- a/packages/quicktype-core/src/rewrites/FlattenUnions.ts +++ b/packages/quicktype-core/src/rewrites/FlattenUnions.ts @@ -16,6 +16,7 @@ export function flattenUnions( stringTypeMapping: StringTypeMapping, conflateNumbers: boolean, makeObjectTypes: boolean, + preserveDistinctObjects: boolean, debugPrintReconstitution: boolean, ): [TypeGraph, boolean] { let needsRepeat = false; @@ -140,6 +141,7 @@ export function flattenUnions( builder, makeObjectTypes, true, + preserveDistinctObjects, unifyTypeRefs, ); return unifyTypes( diff --git a/test/inputs/schema/one-of-objects.1.json b/test/inputs/schema/one-of-objects.1.json new file mode 100644 index 0000000000..0f40c167e9 --- /dev/null +++ b/test/inputs/schema/one-of-objects.1.json @@ -0,0 +1 @@ +[{ "aa": "aa", "bb": "bb" }, { "cc": "cc", "dd": "dd" }] diff --git a/test/inputs/schema/one-of-objects.2.fail.one-of.json b/test/inputs/schema/one-of-objects.2.fail.one-of.json new file mode 100644 index 0000000000..d7d682342e --- /dev/null +++ b/test/inputs/schema/one-of-objects.2.fail.one-of.json @@ -0,0 +1 @@ +[{ "aa": "aa", "cc": "cc", "dd": "dd" }] diff --git a/test/inputs/schema/one-of-objects.schema b/test/inputs/schema/one-of-objects.schema new file mode 100644 index 0000000000..957e13b0a9 --- /dev/null +++ b/test/inputs/schema/one-of-objects.schema @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OneOfObjects", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "aa": { "type": "string" }, + "bb": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "cc": { "type": "string" }, + "dd": { "type": "string" } + } + } + ] + } +} diff --git a/test/languages.ts b/test/languages.ts index 7f80f5e3b1..d49b137ffe 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -58,6 +58,7 @@ const skipsMapValueValidation = [ export type LanguageFeature = | "enum" | "union" + | "one-of" | "no-defaults" | "strict-optional" | "date-time" @@ -1010,7 +1011,14 @@ export const TypeScriptLanguage: Language = { "e8b04.json", ], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "strict-optional", "date-time"], + features: [ + "enum", + "union", + "one-of", + "no-defaults", + "strict-optional", + "date-time", + ], output: "TopLevel.ts", topLevel: "TopLevel", skipJSON: [], @@ -1053,7 +1061,14 @@ export const JavaScriptLanguage: Language = { diffViaSchema: false, skipDiffViaSchema: [], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "strict-optional", "date-time"], + features: [ + "enum", + "union", + "one-of", + "no-defaults", + "strict-optional", + "date-time", + ], output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [], @@ -1105,7 +1120,7 @@ export const FlowLanguage: Language = { diffViaSchema: false, skipDiffViaSchema: [], allowMissingNull: false, - features: ["enum", "union", "no-defaults", "strict-optional"], + features: ["enum", "union", "one-of", "no-defaults", "strict-optional"], output: "TopLevel.js", topLevel: "TopLevel", skipJSON: [], diff --git a/test/unit/schema-object-unions.test.ts b/test/unit/schema-object-unions.test.ts new file mode 100644 index 0000000000..987e2790ed --- /dev/null +++ b/test/unit/schema-object-unions.test.ts @@ -0,0 +1,49 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function generateTypeScript( + operation: "oneOf" | "anyOf", +): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "AssetIDEither", + schema: JSON.stringify({ + [operation]: [ + { + type: "object", + required: ["id"], + properties: { id: { type: "integer" } }, + }, + { + type: "object", + required: ["externalId"], + properties: { externalId: { type: "string" } }, + }, + ], + }), + }); + + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "typescript", + rendererOptions: { "just-types": true }, + }); + return result.lines.join("\n"); +} + +describe("JSON Schema object unions (issue #1266)", () => { + test.each([ + "oneOf", + "anyOf", + ] as const)("%s preserves object alternatives", async (operation) => { + const output = await generateTypeScript(operation); + + expect(output).toContain("export type AssetIDEither ="); + expect(output).toContain(" | "); + expect(output).toContain("id: number;"); + expect(output).toContain("externalId: string;"); + expect(output).not.toContain("export interface AssetIDEither"); + }); +}); From 1faea20c732f9ddf6ccdd8dff7e539233c200539 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:04:13 -0400 Subject: [PATCH 2/6] test(php): skip one-of-objects.schema (top-level array unsupported) The new one-of-objects.schema fixture uses a top-level array, which the PHP driver does not support (generated TopLevel::from expects stdClass, not array) - the same pre-existing limitation for which union.schema is already skipped. The oneOf-of-objects behavior this fixture exercises is only enabled for TS/JS/Flow, so PHP output is unaffected. Co-Authored-By: Claude --- test/languages.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index d49b137ffe..c7f86b9825 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1880,6 +1880,10 @@ export const PHPLanguage: Language = { "top-level-enum.schema", // The driver does not support top-level arrays. "union.schema", + // Top-level array schema; the driver does not support top-level + // arrays (same reason as union.schema). The oneOf-of-objects + // behavior this fixture targets is only enabled for TS/JS/Flow. + "one-of-objects.schema", ], rendererOptions: {}, quickTestRendererOptions: [], From fd6c2c4d0824609a7e7bd476754786f99650bdf6 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:27:59 -0400 Subject: [PATCH 3/6] test: remove unit test coverage duplicated by fixtures (#1266) Co-Authored-By: Claude --- test/unit/schema-object-unions.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/unit/schema-object-unions.test.ts b/test/unit/schema-object-unions.test.ts index 987e2790ed..268953b69e 100644 --- a/test/unit/schema-object-unions.test.ts +++ b/test/unit/schema-object-unions.test.ts @@ -34,11 +34,13 @@ async function generateTypeScript( } describe("JSON Schema object unions (issue #1266)", () => { - test.each([ - "oneOf", - "anyOf", - ] as const)("%s preserves object alternatives", async (operation) => { - const output = await generateTypeScript(operation); + // The oneOf case is covered by the one-of-objects.schema fixture (with its + // one-of-objects.2.fail.one-of.json expected-failure sample) running for + // TypeScript/JavaScript/Flow in CI. No fixture exercises anyOf, so this + // unit test guards that anyOf alternatives are preserved as a union rather + // than merged into a single interface. + test("anyOf preserves object alternatives", async () => { + const output = await generateTypeScript("anyOf"); expect(output).toContain("export type AssetIDEither ="); expect(output).toContain(" | "); From a14a998b730f42e1773cc2ac50796eb6c8c7305a Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 20:51:44 -0400 Subject: [PATCH 4/6] fix CI: kotlinx top-level array typealias used non-generic JsonArray (#1266) emitTopLevelArray unconditionally emitted `typealias X = JsonArray`, but kotlinx.serialization.json.JsonArray is a concrete, non-generic class, not a generic container. This only compiled by accident for element types that stringify to JsonObject/JsonElement (the one case where a bare `JsonArray` is correct). The new one-of-objects.schema fixture (top-level array of a oneOf union of distinct objects) exposed this for the first time, breaking the kotlinx/schema-kotlinx CI job. Mirror the same JsonObject/JsonElement special case already used in arrayType(): emit bare `JsonArray` for raw JSON element arrays, and a real generic `List` otherwise. Co-Authored-By: gpt-5.6-sol via pi --- .../src/language/Kotlin/KotlinXRenderer.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index 99d789ed18..12bca2906b 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -93,8 +93,13 @@ export class KotlinXRenderer extends KotlinRenderer { } protected emitTopLevelArray(t: ArrayType, name: Name): void { - const elementType = this.kotlinType(t.items); - this.emitLine(["typealias ", name, " = JsonArray<", elementType, ">"]); + const elementType = this.kotlinType(t.items, false, true); + const elementName = this.sourcelikeToString(elementType); + if (elementName === "JsonObject" || elementName === "JsonElement") { + this.emitLine(["typealias ", name, " = JsonArray"]); + } else { + this.emitLine(["typealias ", name, " = List<", elementType, ">"]); + } } protected emitUsageHeader(): void { From 1ff0e7ea39220872a221c63dd64c2b31a4b24e60 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 20 Jul 2026 21:07:19 -0400 Subject: [PATCH 5/6] fix CI: correct stale enum-order expectation in cjson-enum-default test (#1266) test/unit/cjson-enum-default.test.ts (landed on master in 89d9f2ad) hardcoded its expected enum body in alphabetical order, but master's ConvenienceRenderer.forEachEnumCase (f58485ed, "preserve JSON Schema enum case order") already stopped alphabetizing enum cases in favor of source declaration order. The test was never updated to match, so it fails identically on master alone (verified independent of this PR's changes) and blocks PR #1266's CI once merged with base. Correct the expected case order to the declaration order (state, config, heartbeat) that quicktype now intentionally produces; the test's actual purpose (asserting cJSON enums start at 1) is unaffected. Co-Authored-By: gpt-5.6-sol via pi --- test/unit/cjson-enum-default.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/cjson-enum-default.test.ts b/test/unit/cjson-enum-default.test.ts index b2c02d60ee..0ecd24cb92 100644 --- a/test/unit/cjson-enum-default.test.ts +++ b/test/unit/cjson-enum-default.test.ts @@ -28,9 +28,9 @@ describe("cJSON enum invalid value", () => { const output = await cJSONOutput(); expect(output).toContain(`enum Subscription { - SUBSCRIPTION_CONFIG = 1, + SUBSCRIPTION_STATE = 1, + SUBSCRIPTION_CONFIG, SUBSCRIPTION_HEARTBEAT, - SUBSCRIPTION_STATE, };`); expect(output).toContain("enum Subscription x = 0;"); }); From 4cb1b3950b7b5e7ec0ca330f5d6fd9b834c46a1c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Wed, 22 Jul 2026 15:55:27 -0400 Subject: [PATCH 6/6] test: isolate object-union fixture from unrelated languages --- .../src/language/Kotlin/KotlinXRenderer.ts | 9 +--- test/inputs/schema/one-of-objects.1.json | 2 +- .../schema/one-of-objects.2.fail.one-of.json | 2 +- test/inputs/schema/one-of-objects.schema | 45 +++++++++++-------- test/languages.ts | 4 -- 5 files changed, 30 insertions(+), 32 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index 12bca2906b..99d789ed18 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -93,13 +93,8 @@ export class KotlinXRenderer extends KotlinRenderer { } protected emitTopLevelArray(t: ArrayType, name: Name): void { - const elementType = this.kotlinType(t.items, false, true); - const elementName = this.sourcelikeToString(elementType); - if (elementName === "JsonObject" || elementName === "JsonElement") { - this.emitLine(["typealias ", name, " = JsonArray"]); - } else { - this.emitLine(["typealias ", name, " = List<", elementType, ">"]); - } + const elementType = this.kotlinType(t.items); + this.emitLine(["typealias ", name, " = JsonArray<", elementType, ">"]); } protected emitUsageHeader(): void { diff --git a/test/inputs/schema/one-of-objects.1.json b/test/inputs/schema/one-of-objects.1.json index 0f40c167e9..f2b233dcf0 100644 --- a/test/inputs/schema/one-of-objects.1.json +++ b/test/inputs/schema/one-of-objects.1.json @@ -1 +1 @@ -[{ "aa": "aa", "bb": "bb" }, { "cc": "cc", "dd": "dd" }] +{ "items": [{ "aa": "aa", "bb": "bb" }, { "cc": "cc", "dd": "dd" }] } diff --git a/test/inputs/schema/one-of-objects.2.fail.one-of.json b/test/inputs/schema/one-of-objects.2.fail.one-of.json index d7d682342e..49dd2883fb 100644 --- a/test/inputs/schema/one-of-objects.2.fail.one-of.json +++ b/test/inputs/schema/one-of-objects.2.fail.one-of.json @@ -1 +1 @@ -[{ "aa": "aa", "cc": "cc", "dd": "dd" }] +{ "items": [{ "aa": "aa", "cc": "cc", "dd": "dd" }] } diff --git a/test/inputs/schema/one-of-objects.schema b/test/inputs/schema/one-of-objects.schema index 957e13b0a9..e2d54e9342 100644 --- a/test/inputs/schema/one-of-objects.schema +++ b/test/inputs/schema/one-of-objects.schema @@ -1,25 +1,32 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "OneOfObjects", - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "aa": { "type": "string" }, - "bb": { "type": "string" } - } - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "cc": { "type": "string" }, - "dd": { "type": "string" } - } + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "aa": { "type": "string" }, + "bb": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "cc": { "type": "string" }, + "dd": { "type": "string" } + } + } + ] } - ] + } } } diff --git a/test/languages.ts b/test/languages.ts index c88be43314..30988dc39b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1977,10 +1977,6 @@ export const PHPLanguage: Language = { "top-level-enum.schema", // The driver does not support top-level arrays. "union.schema", - // Top-level array schema; the driver does not support top-level - // arrays (same reason as union.schema). The oneOf-of-objects - // behavior this fixture targets is only enabled for TS/JS/Flow. - "one-of-objects.schema", ], rendererOptions: {}, quickTestRendererOptions: [],