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 66b1c4104b..96bec268d3 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -46,6 +46,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 { @@ -1251,6 +1255,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 6ce623d1b5..d8ed5e9296 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), "[]"]); } + 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 { const emptyObjectType = this.emptyObjectTypeFor(t); if (emptyObjectType !== undefined) { @@ -138,10 +145,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)); @@ -221,18 +225,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 b162b9384e..d656fb1dff 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 get emptyObjectType(): string { return "object"; } 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..f2b233dcf0 --- /dev/null +++ b/test/inputs/schema/one-of-objects.1.json @@ -0,0 +1 @@ +{ "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 new file mode 100644 index 0000000000..49dd2883fb --- /dev/null +++ b/test/inputs/schema/one-of-objects.2.fail.one-of.json @@ -0,0 +1 @@ +{ "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 new file mode 100644 index 0000000000..e2d54e9342 --- /dev/null +++ b/test/inputs/schema/one-of-objects.schema @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OneOfObjects", + "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 120f3170b0..d85e8f3a81 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -70,6 +70,7 @@ const skipsArrayElementValidation = ["issue2680-top-level-array.schema"]; export type LanguageFeature = | "enum" | "union" + | "one-of" | "no-defaults" | "strict-optional" | "date-time" @@ -1115,7 +1116,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: [], @@ -1159,7 +1167,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: [], @@ -1215,7 +1230,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..268953b69e --- /dev/null +++ b/test/unit/schema-object-unions.test.ts @@ -0,0 +1,51 @@ +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)", () => { + // 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(" | "); + expect(output).toContain("id: number;"); + expect(output).toContain("externalId: string;"); + expect(output).not.toContain("export interface AssetIDEither"); + }); +});