diff --git a/src/generator.ts b/src/generator.ts index dcee32ff..30b71fdc 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -20,7 +20,7 @@ export function generate(ast: AST, options = DEFAULT_OPTIONS): string { return ( [ options.bannerComment, - declareNamedTypes(ast, options, ast.standaloneName!), + declareNamedTypes(ast, options, ast.standaloneName!, new Set(), new Set()), declareNamedInterfaces(ast, options, ast.standaloneName!), declareEnums(ast, options), ] @@ -101,18 +101,33 @@ function declareNamedInterfaces(ast: AST, options: Options, rootASTName: string, return type } -function declareNamedTypes(ast: AST, options: Options, rootASTName: string, processed = new Set()): string { +function declareNamedTypes( + ast: AST, + options: Options, + rootASTName: string, + processed = new Set(), + outputtedNames = new Set(), +): string { if (processed.has(ast)) { return '' } processed.add(ast) + // Helper to generate standalone type only if name hasn't been used + const generateStandaloneTypeOnce = (ast: ASTWithStandaloneName): string | undefined => { + if (outputtedNames.has(ast.standaloneName)) { + return undefined + } + outputtedNames.add(ast.standaloneName) + return generateStandaloneType(ast, options) + } + switch (ast.type) { case 'ARRAY': return [ - declareNamedTypes(ast.params, options, rootASTName, processed), - hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined, + declareNamedTypes(ast.params, options, rootASTName, processed, outputtedNames), + hasStandaloneName(ast) ? generateStandaloneTypeOnce(ast) : undefined, ] .filter(Boolean) .join('\n') @@ -123,7 +138,7 @@ function declareNamedTypes(ast: AST, options: Options, rootASTName: string, proc .map( ast => (ast.standaloneName === rootASTName || options.declareExternallyReferenced) && - declareNamedTypes(ast, options, rootASTName, processed), + declareNamedTypes(ast, options, rootASTName, processed, outputtedNames), ) .filter(Boolean) .join('\n') @@ -131,20 +146,20 @@ function declareNamedTypes(ast: AST, options: Options, rootASTName: string, proc case 'TUPLE': case 'UNION': return [ - hasStandaloneName(ast) ? generateStandaloneType(ast, options) : undefined, + hasStandaloneName(ast) ? generateStandaloneTypeOnce(ast) : undefined, ast.params - .map(ast => declareNamedTypes(ast, options, rootASTName, processed)) + .map(ast => declareNamedTypes(ast, options, rootASTName, processed, outputtedNames)) .filter(Boolean) .join('\n'), 'spreadParam' in ast && ast.spreadParam - ? declareNamedTypes(ast.spreadParam, options, rootASTName, processed) + ? declareNamedTypes(ast.spreadParam, options, rootASTName, processed, outputtedNames) : undefined, ] .filter(Boolean) .join('\n') default: if (hasStandaloneName(ast)) { - return generateStandaloneType(ast, options) + return generateStandaloneTypeOnce(ast) || '' } return '' } diff --git a/src/optimizer.ts b/src/optimizer.ts index c8e1148f..1bb86148 100644 --- a/src/optimizer.ts +++ b/src/optimizer.ts @@ -59,9 +59,85 @@ export function optimize(ast: AST, options: Options, processed = new Set()) optimizedAST.params = params } - return Object.assign(optimizedAST, { - params: optimizedAST.params.map(_ => optimize(_, options, processed)), - }) + // For INTERSECTION: simplify A & (A | null) -> A | null + // Also handle cases where intersection contains duplicate types + if (ast.type === 'INTERSECTION') { + // First, handle the A & (A | null) -> A | null pattern + if (optimizedAST.params.length === 2) { + const [first, second] = optimizedAST.params + + // Check if one is INTERFACE and the other is UNION containing that INTERFACE + if (first.type === 'INTERFACE' && second.type === 'UNION') { + const firstType = generateType(first, options) + // Look for an interface member in the union that matches first + for (const unionParam of second.params) { + if (unionParam.type === 'INTERFACE') { + const unionParamType = generateType(unionParam, options) + if (firstType === unionParamType) { + // Found a match - check if the rest of the union is just adding null or similar + const otherMembers = second.params.filter(_ => _ !== unionParam) + if (otherMembers.length > 0) { + log('cyan', 'optimizer', 'A & (A | ...) -> A | ...', optimizedAST) + // Preserve standaloneName, keyName, comment from the intersection + return { + ...second, + standaloneName: optimizedAST.standaloneName, + keyName: optimizedAST.keyName, + comment: optimizedAST.comment, + deprecated: optimizedAST.deprecated, + } + } + } + } + } + } + + // Check the reverse: (A | ...) & A -> A | ... + if (second.type === 'INTERFACE' && first.type === 'UNION') { + const secondType = generateType(second, options) + // Look for an interface member in the union that matches second + for (const unionParam of first.params) { + if (unionParam.type === 'INTERFACE') { + const unionParamType = generateType(unionParam, options) + if (secondType === unionParamType) { + // Found a match - check if the rest of the union is just adding null or similar + const otherMembers = first.params.filter(_ => _ !== unionParam) + if (otherMembers.length > 0) { + log('cyan', 'optimizer', '(A | ...) & A -> A | ...', optimizedAST) + // Preserve standaloneName, keyName, comment from the intersection + return { + ...first, + standaloneName: optimizedAST.standaloneName, + keyName: optimizedAST.keyName, + comment: optimizedAST.comment, + deprecated: optimizedAST.deprecated, + } + } + } + } + } + } + } + + // Second, handle intersections with more than 2 members that contain duplicates + // This handles cases like A & B & A & B -> A & B + if (optimizedAST.params.length > 2) { + const uniqueParams = uniqBy(optimizedAST.params, _ => generateType(_, options)) + if (uniqueParams.length < optimizedAST.params.length) { + log('cyan', 'optimizer', 'Intersection with duplicates simplified', optimizedAST) + optimizedAST.params = uniqueParams + + // If we're left with just 1 param, return it directly + if (uniqueParams.length === 1) { + log('cyan', 'optimizer', 'Single-member intersection unwrapped', optimizedAST) + return uniqueParams[0] + } + } + } + } + + // Params were already optimized at line 27, so just return + return optimizedAST default: return ast } diff --git a/test/__snapshots__/test/test.ts.md b/test/__snapshots__/test/test.ts.md index 0d8b981b..ca45288a 100644 --- a/test/__snapshots__/test/test.ts.md +++ b/test/__snapshots__/test/test.ts.md @@ -199,6 +199,40 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## allOfWithNestedRef.js + +> Expected output to match snapshot for e2e test: allOfWithNestedRef.js + + `/* eslint-disable */␊ + /**␊ + * This file was automatically generated by json-schema-to-typescript.␊ + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,␊ + * and run json-schema-to-typescript to regenerate this file.␊ + */␊ + ␊ + export type AllOfWithNestedRef = BaseDefinition & ExtendedDefinition;␊ + export type SharedProperty = {␊ + foo?: (string | null)[] | null;␊ + bar?: string | null;␊ + [k: string]: unknown;␊ + } | null;␊ + ␊ + export interface BaseDefinition {␊ + sharedProperty?: SharedProperty;␊ + [k: string]: unknown;␊ + }␊ + export interface ExtendedDefinition {␊ + extendedProperty?: {␊ + sharedProperty?: SharedProperty &␊ + ({␊ + [k: string]: unknown;␊ + } | null);␊ + [k: string]: unknown;␊ + } | null;␊ + [k: string]: unknown;␊ + }␊ + ` + ## anyOf.js > Expected output to match snapshot for e2e test: anyOf.js @@ -440405,17 +440439,7 @@ Generated by [AVA](https://avajs.dev). */␊ exit_code?: number␊ [k: string]: unknown␊ - } & ({␊ - /**␊ - * output of the postdeploy script␊ - */␊ - output?: string␊ - /**␊ - * The exit code of the postdeploy script␊ - */␊ - exit_code?: number␊ - [k: string]: unknown␊ - } | null))␊ + } | null)␊ /**␊ * buildpacks executed for this build, in order␊ */␊ @@ -440429,19 +440453,13 @@ Generated by [AVA](https://avajs.dev). /**␊ * release resulting from the build␊ */␊ - export type Release = ({␊ - /**␊ - * unique identifier of release␊ - */␊ - id?: string␊ - [k: string]: unknown␊ - } & (null | {␊ + export type Release = (null | {␊ /**␊ * unique identifier of release␊ */␊ id?: string␊ [k: string]: unknown␊ - }))␊ + })␊ /**␊ * price information for this dyno size␊ */␊ @@ -440465,17 +440483,7 @@ Generated by [AVA](https://avajs.dev). */␊ name?: string␊ [k: string]: unknown␊ - } & ({␊ - /**␊ - * unique identifier of add-on␊ - */␊ - id?: string␊ - /**␊ - * globally unique name of the add-on␊ - */␊ - name?: string␊ - [k: string]: unknown␊ - } | null))␊ + } | null)␊ /**␊ * The scope of access OAuth authorization allows␊ */␊ @@ -444001,66 +444009,7 @@ Generated by [AVA](https://avajs.dev). * and run json-schema-to-typescript to regenerate this file.␊ */␊ ␊ - export type CoreSchemaMetaSchema = {␊ - $id?: string;␊ - $schema?: string;␊ - $ref?: string;␊ - $comment?: string;␊ - title?: string;␊ - description?: string;␊ - default?: unknown;␊ - readOnly?: boolean;␊ - writeOnly?: boolean;␊ - examples?: unknown[];␊ - multipleOf?: number;␊ - maximum?: number;␊ - exclusiveMaximum?: number;␊ - minimum?: number;␊ - exclusiveMinimum?: number;␊ - maxLength?: NonNegativeInteger;␊ - minLength?: NonNegativeIntegerDefault0;␊ - pattern?: string;␊ - additionalItems?: CoreSchemaMetaSchema;␊ - items?: CoreSchemaMetaSchema | SchemaArray;␊ - maxItems?: NonNegativeInteger;␊ - minItems?: NonNegativeIntegerDefault0;␊ - uniqueItems?: boolean;␊ - contains?: CoreSchemaMetaSchema;␊ - maxProperties?: NonNegativeInteger;␊ - minProperties?: NonNegativeIntegerDefault0;␊ - required?: StringArray;␊ - additionalProperties?: CoreSchemaMetaSchema;␊ - definitions?: {␊ - [k: string]: CoreSchemaMetaSchema;␊ - };␊ - properties?: {␊ - [k: string]: CoreSchemaMetaSchema;␊ - };␊ - patternProperties?: {␊ - [k: string]: CoreSchemaMetaSchema;␊ - };␊ - dependencies?: {␊ - [k: string]: CoreSchemaMetaSchema | StringArray;␊ - };␊ - propertyNames?: CoreSchemaMetaSchema;␊ - const?: unknown;␊ - /**␊ - * @minItems 1␊ - */␊ - enum?: [unknown, ...unknown[]];␊ - type?: SimpleTypes | [SimpleTypes, ...SimpleTypes[]];␊ - format?: string;␊ - contentMediaType?: string;␊ - contentEncoding?: string;␊ - if?: CoreSchemaMetaSchema;␊ - then?: CoreSchemaMetaSchema;␊ - else?: CoreSchemaMetaSchema;␊ - allOf?: SchemaArray;␊ - anyOf?: SchemaArray;␊ - oneOf?: SchemaArray;␊ - not?: CoreSchemaMetaSchema;␊ - [k: string]: unknown;␊ - } & (␊ + export type CoreSchemaMetaSchema =␊ | {␊ $id?: string;␊ $schema?: string;␊ @@ -444121,8 +444070,7 @@ Generated by [AVA](https://avajs.dev). not?: CoreSchemaMetaSchema;␊ [k: string]: unknown;␊ }␊ - | boolean␊ - );␊ + | boolean;␊ export type NonNegativeInteger = number;␊ export type NonNegativeIntegerDefault0 = NonNegativeInteger;␊ /**␊ @@ -447933,12 +447881,14 @@ Generated by [AVA](https://avajs.dev). /**␊ * A person who has been involved in creating or maintaining this package.␊ */␊ - export type Person = {␊ - name: string;␊ - url?: string;␊ - email?: string;␊ - [k: string]: unknown;␊ - } & Person1;␊ + export type Person =␊ + | {␊ + name: string;␊ + url?: string;␊ + email?: string;␊ + [k: string]: unknown;␊ + }␊ + | string;␊ export type Person1 =␊ | {␊ name: string;␊ diff --git a/test/__snapshots__/test/test.ts.snap b/test/__snapshots__/test/test.ts.snap index 79cd4741..533150fa 100644 Binary files a/test/__snapshots__/test/test.ts.snap and b/test/__snapshots__/test/test.ts.snap differ diff --git a/test/e2e/allOfWithNestedRef.ts b/test/e2e/allOfWithNestedRef.ts new file mode 100644 index 00000000..522ce18b --- /dev/null +++ b/test/e2e/allOfWithNestedRef.ts @@ -0,0 +1,51 @@ +// Tests allOf with nested $ref that creates redundant intersections +// Pattern: Schema A defines property X, Schema B references A and also defines X with allOf +// This creates A & (A | null) which should simplify to A | null +export const input = { + allOf: [ + { + $ref: '#/definitions/baseDefinition', + }, + { + $ref: '#/definitions/extendedDefinition', + }, + ], + definitions: { + baseDefinition: { + properties: { + sharedProperty: { + type: ['object', 'null'], + properties: { + foo: { + type: ['array', 'null'], + items: { + type: ['string', 'null'], + }, + }, + bar: { + type: ['string', 'null'], + }, + }, + }, + }, + }, + extendedDefinition: { + properties: { + extendedProperty: { + type: ['object', 'null'], + properties: { + sharedProperty: { + type: ['object', 'null'], + allOf: [ + { + $ref: '#/definitions/baseDefinition/properties/sharedProperty', + }, + ], + properties: {}, + }, + }, + }, + }, + }, + }, +}