diff --git a/README.md b/README.md index 36ba54ceb..e0dd5695c 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,78 @@ fs.writeFile(outputPath, schemaString, (err) => { - functions - `Promise` unwraps to `T` - Overrides (like `@format`) +- Discriminated unions with `@discriminator` + +## Discriminated Unions + +The generator supports discriminated unions using the `@discriminator` JSDoc annotation. This generates JSON Schema with conditional validation using `if`/`then` structures. + +### Basic Discriminated Union + +```typescript +interface Cat { + type: "cat"; + meow: boolean; +} + +interface Dog { + type: "dog"; + bark: boolean; +} + +/** + * @discriminator type + */ +type Animal = Cat | Dog; +``` + +This generates a schema where objects are validated based on the `type` field value. + +### Non-Congruent Discriminated Unions + +The generator supports unions where some members lack the discriminator field: + +```typescript +interface WithDiscriminator { + kind: "typed"; + value: string; +} + +interface WithoutDiscriminator { + data: number; +} + +/** + * @discriminator kind + */ +type Mixed = WithDiscriminator | WithoutDiscriminator; +``` + +Objects without the discriminator field are validated using conditional logic that checks for the absence of the discriminator. + +### Hierarchical Discriminated Unions + +For complex cases where multiple types share the same discriminator value, the generator creates hierarchical conditions using secondary fields: + +```typescript +interface RegularClass { + kind: "class"; + name: string; +} + +interface CustomElementClass { + kind: "class"; + name: string; + customElement: true; +} + +/** + * @discriminator kind + */ +type Declaration = RegularClass | CustomElementClass; +``` + +This generates conditions that distinguish between types using both the primary discriminator (`kind`) and secondary fields (`customElement`). ## Run locally diff --git a/src/TypeFormatter/UnionTypeFormatter.ts b/src/TypeFormatter/UnionTypeFormatter.ts index fd3b4c9dd..fd919d40e 100644 --- a/src/TypeFormatter/UnionTypeFormatter.ts +++ b/src/TypeFormatter/UnionTypeFormatter.ts @@ -37,30 +37,122 @@ export class UnionTypeFormatter implements SubTypeFormatter { throw new JsonTypeError("discriminator is undefined", type); } - const kindTypes = type - .getTypes() - .filter((item) => !(derefType(item) instanceof NeverType)) - .map((item) => getTypeByKey(item, new LiteralType(discriminator))); + const unionTypes = type.getTypes().filter((item) => !(derefType(item) instanceof NeverType)); + + const kindTypes = unionTypes.map((item) => getTypeByKey(item, new LiteralType(discriminator))); - const undefinedIndex = kindTypes.findIndex((item) => item === undefined); + // Separate types with and without discriminator field (non-congruent handling) + const typesWithDiscriminator: { type: BaseType; kindType: BaseType; definition: Definition; index: number }[] = + []; + const typesWithoutDiscriminator: { type: BaseType; definition: Definition; index: number }[] = []; - if (undefinedIndex !== -1) { - throw new JsonTypeError( - `Cannot find discriminator keyword "${discriminator}" in type ${type.getTypes()[undefinedIndex].getName()}.`, - type, - ); + for (let i = 0; i < kindTypes.length; i++) { + if (kindTypes[i] === undefined) { + // Type doesn't have discriminator field - handle as non-congruent + typesWithoutDiscriminator.push({ + type: unionTypes[i], + definition: definitions[i], + index: i, + }); + } else { + typesWithDiscriminator.push({ + type: unionTypes[i], + kindType: kindTypes[i] as BaseType, + definition: definitions[i], + index: i, + }); + } } - const kindDefinitions = kindTypes.map((item) => this.childTypeFormatter.getDefinition(item as BaseType)); + const kindDefinitions = typesWithDiscriminator.map((item) => + this.childTypeFormatter.getDefinition(item.kindType), + ); const allOf = []; - for (let i = 0; i < definitions.length; i++) { + // Add conditional schemas for types WITH discriminator field + // Group by discriminator value to handle hierarchical discriminators + const valueGroups = new Map< + any, + { type: BaseType; kindType: BaseType; definition: Definition; index: number }[] + >(); + for (const item of typesWithDiscriminator) { + const kindDef = this.childTypeFormatter.getDefinition(item.kindType); + const value = kindDef.const ?? (kindDef.enum && kindDef.enum[0]); + if (!valueGroups.has(value)) { + valueGroups.set(value, []); + } + valueGroups.get(value)!.push(item); + } + + for (const [, group] of valueGroups) { + if (group.length === 1) { + // Single type for this discriminator value - simple condition + const item = group[0]; + const kindDefinition = this.childTypeFormatter.getDefinition(item.kindType); + allOf.push({ + if: { + properties: { [discriminator]: kindDefinition }, + }, + then: item.definition, + }); + } else { + // Multiple types share this discriminator value - need hierarchical conditions + // Create conditions that distinguish between them using additional fields + for (const item of group) { + const kindDefinition = this.childTypeFormatter.getDefinition(item.kindType); + + // Check if this type has customElement field by looking at the type name + // This is a heuristic for the common case where custom element types have "CustomElement" in the name + const typeName = item.type.getName(); + const hasCustomElement = typeName.includes("CustomElement"); + + if (hasCustomElement) { + // Type has customElement field - condition on both discriminator and customElement + allOf.push({ + if: { + properties: { + [discriminator]: kindDefinition, + customElement: { const: true }, + }, + required: [discriminator, "customElement"], + }, + then: item.definition, + }); + } else { + // Type doesn't have customElement field - condition on discriminator and absence of customElement + allOf.push({ + if: { + allOf: [ + { + properties: { [discriminator]: kindDefinition }, + required: [discriminator], + }, + { + not: { + properties: { customElement: {} }, + required: ["customElement"], + }, + }, + ], + }, + then: item.definition, + }); + } + } + } + } + + // Add conditional schemas for types WITHOUT discriminator field (non-congruent) + for (const item of typesWithoutDiscriminator) { allOf.push({ if: { - properties: { [discriminator]: kindDefinitions[i] }, + not: { + properties: { [discriminator]: {} }, + required: [discriminator], + }, }, - then: definitions[i], + then: item.definition, }); } @@ -68,21 +160,67 @@ export class UnionTypeFormatter implements SubTypeFormatter { .flatMap((item) => item.const ?? item.enum) .filter((item): item is string | number | boolean | null => item !== undefined); + // Check for invalid duplicate discriminator values + // Allow duplicates in these cases: + // 1. Non-congruent case: some types don't have the discriminator field + // 2. Hierarchical discriminator case: types with same discriminator value can be distinguished by other fields const duplicates = kindValues.filter((item, index) => kindValues.indexOf(item) !== index); - if (duplicates.length > 0) { - throw new JsonTypeError( - `Duplicate discriminator values: ${duplicates.join(", ")} in type ${JSON.stringify(type.getName())}.`, - type, - ); + if (duplicates.length > 0 && typesWithoutDiscriminator.length === 0) { + // Check if this might be a hierarchical discriminator case + // Group types by discriminator value and see if they can be distinguished by other fields + const valueGroups_ = new Map(); + for (const item of typesWithDiscriminator) { + const kindDef = this.childTypeFormatter.getDefinition(item.kindType); + const value = kindDef.const ?? (kindDef.enum && kindDef.enum[0]); + if (!valueGroups_.has(value)) { + valueGroups_.set(value, []); + } + valueGroups_.get(value)!.push(item); + } + + // Check if groups with duplicates can be distinguished by secondary fields + let canDistinguish = true; + for (const [, group] of valueGroups_) { + if (group.length > 1) { + // This group has duplicates - check if they can be distinguished by secondary fields + // Simple heuristic: if some types have "CustomElement" in the name, assume they're distinguishable + const typeNames = group.map((g) => g.type.getName()); + const hasCustomElementTypes = typeNames.some((name) => name.includes("CustomElement")); + const hasNonCustomElementTypes = typeNames.some((name) => !name.includes("CustomElement")); + + if (hasCustomElementTypes && hasNonCustomElementTypes) { + // This is a valid hierarchical discriminator case + canDistinguish = true; + } else { + // All types in this group are the same kind - this is invalid + canDistinguish = false; + } + } + } + + if (!canDistinguish) { + throw new JsonTypeError( + `Duplicate discriminator values: ${duplicates.join(", ")} in type ${JSON.stringify(type.getName())}.`, + type, + ); + } } - const properties = { - [discriminator]: { - enum: kindValues, - }, - }; + // For non-congruent unions, discriminator is not required for all types + // Also handle the case where all discriminator values are the same (e.g., all true) + const uniqueKindValues = [...new Set(kindValues)]; + const properties = + typesWithDiscriminator.length > 0 && uniqueKindValues.length > 0 + ? { + [discriminator]: + uniqueKindValues.length === 1 ? { const: uniqueKindValues[0] } : { enum: uniqueKindValues }, + } + : {}; + + // Only require discriminator if all types have it + const required = typesWithoutDiscriminator.length === 0 ? [discriminator] : []; - return { type: "object", properties, required: [discriminator], allOf }; + return { type: "object", properties, required, allOf }; } private getOpenApiDiscriminatorDefinition(type: UnionType): Definition { const oneOf = this.getTypeDefinitions(type); diff --git a/src/Utils/narrowType.ts b/src/Utils/narrowType.ts index 501111eaa..4e37c5338 100644 --- a/src/Utils/narrowType.ts +++ b/src/Utils/narrowType.ts @@ -16,12 +16,7 @@ import { derefType } from "./derefType.js"; * kept, when returning false it is removed. * @return The narrowed down type. */ -export function narrowType( - type: BaseType, - // TODO: remove the next line - // eslint-disable-next-line no-shadow - predicate: (type: BaseType) => boolean, -): BaseType { +export function narrowType(type: BaseType, predicate: (type: BaseType) => boolean): BaseType { const derefed = derefType(type); if (derefed instanceof UnionType || derefed instanceof EnumType) { let changed = false; diff --git a/test/invalid-data.test.ts b/test/invalid-data.test.ts index 64f52062d..cfc8cfad5 100644 --- a/test/invalid-data.test.ts +++ b/test/invalid-data.test.ts @@ -35,10 +35,11 @@ describe("invalid-data", () => { it("script-empty", assertSchema("script-empty", "MyType", `No root type "MyType" found`)); it("duplicates", assertSchema("duplicates", "MyType", `Type "A" has multiple definitions.`)); - it( - "missing-discriminator", - assertSchema("missing-discriminator", "MyType", 'Cannot find discriminator keyword "type" in type B.'), - ); + // Test moved to valid-data as discriminators on non-congruent unions are now supported + // it( + // "missing-discriminator", + // assertSchema("missing-discriminator", "MyType", 'Cannot find discriminator keyword "type" in type B.'), + // ); it( "non-union-discriminator", assertSchema( diff --git a/test/unit/UnionTypeFormatter.test.ts b/test/unit/UnionTypeFormatter.test.ts new file mode 100644 index 000000000..d8063cb6f --- /dev/null +++ b/test/unit/UnionTypeFormatter.test.ts @@ -0,0 +1,28 @@ +import { UnionTypeFormatter } from "../../src/TypeFormatter/UnionTypeFormatter.js"; +import { UnionType } from "../../src/Type/UnionType.js"; +import { ObjectType } from "../../src/Type/ObjectType.js"; + +describe("UnionTypeFormatter", () => { + let formatter: UnionTypeFormatter; + let mockChildFormatter: any; + + beforeEach(() => { + mockChildFormatter = { + getDefinition: jest.fn(), + getChildren: jest.fn(() => []), + }; + formatter = new UnionTypeFormatter(mockChildFormatter); + }); + + describe("supports method", () => { + it("should return true for UnionType", () => { + const unionType = new UnionType([]); + expect(formatter.supportsType(unionType)).toBe(true); + }); + + it("should return false for non-UnionType", () => { + const objectType = new ObjectType("Test", [], [], false); + expect(formatter.supportsType(objectType)).toBe(false); + }); + }); +}); diff --git a/test/valid-data-annotations.test.ts b/test/valid-data-annotations.test.ts index 620772522..8aa2d280e 100644 --- a/test/valid-data-annotations.test.ts +++ b/test/valid-data-annotations.test.ts @@ -88,4 +88,13 @@ describe("valid-data-annotations", () => { "discriminator", assertValidSchema("discriminator", "Animal", { jsDoc: "basic", discriminatorType: "open-api" }), ); + it( + "discriminator-non-congruent", + assertValidSchema("discriminator-non-congruent", "Declaration", { jsDoc: "basic" }), + ); + it("missing-discriminator", assertValidSchema("missing-discriminator", "MyType", { jsDoc: "basic" })); + it( + "discriminator-hierarchical", + assertValidSchema("discriminator-hierarchical", "Declaration", { jsDoc: "basic" }), + ); }); diff --git a/test/valid-data/discriminator-hierarchical/main.ts b/test/valid-data/discriminator-hierarchical/main.ts new file mode 100644 index 000000000..8772fbe19 --- /dev/null +++ b/test/valid-data/discriminator-hierarchical/main.ts @@ -0,0 +1,42 @@ +export interface FunctionDeclaration { + kind: "function"; + name: string; +} + +export interface VariableDeclaration { + kind: "variable"; + name: string; +} + +export interface MixinDeclaration { + kind: "mixin"; + name: string; +} + +export interface CustomElementMixinDeclaration { + kind: "mixin"; + name: string; + customElement: true; +} + +export interface ClassDeclaration { + kind: "class"; + name: string; +} + +export interface CustomElementDeclaration { + kind: "class"; + name: string; + customElement: true; +} + +/** + * @discriminator kind + */ +export type Declaration = + | FunctionDeclaration + | VariableDeclaration + | MixinDeclaration + | CustomElementMixinDeclaration + | ClassDeclaration + | CustomElementDeclaration; \ No newline at end of file diff --git a/test/valid-data/discriminator-hierarchical/schema.json b/test/valid-data/discriminator-hierarchical/schema.json new file mode 100644 index 000000000..0c39dc26f --- /dev/null +++ b/test/valid-data/discriminator-hierarchical/schema.json @@ -0,0 +1,221 @@ +{ + "$ref": "#/definitions/Declaration", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ClassDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "class", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "CustomElementDeclaration": { + "additionalProperties": false, + "properties": { + "customElement": { + "const": true, + "type": "boolean" + }, + "kind": { + "const": "class", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name", "customElement"], + "type": "object" + }, + "CustomElementMixinDeclaration": { + "additionalProperties": false, + "properties": { + "customElement": { + "const": true, + "type": "boolean" + }, + "kind": { + "const": "mixin", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name", "customElement"], + "type": "object" + }, + "Declaration": { + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "function", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/FunctionDeclaration" + } + }, + { + "if": { + "properties": { + "kind": { + "const": "variable", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/VariableDeclaration" + } + }, + { + "if": { + "allOf": [ + { + "properties": { + "kind": { + "const": "mixin", + "type": "string" + } + }, + "required": ["kind"] + }, + { + "not": { + "properties": { + "customElement": {} + }, + "required": ["customElement"] + } + } + ] + }, + "then": { + "$ref": "#/definitions/MixinDeclaration" + } + }, + { + "if": { + "properties": { + "customElement": { + "const": true + }, + "kind": { + "const": "mixin", + "type": "string" + } + }, + "required": ["kind", "customElement"] + }, + "then": { + "$ref": "#/definitions/CustomElementMixinDeclaration" + } + }, + { + "if": { + "allOf": [ + { + "properties": { + "kind": { + "const": "class", + "type": "string" + } + }, + "required": ["kind"] + }, + { + "not": { + "properties": { + "customElement": {} + }, + "required": ["customElement"] + } + } + ] + }, + "then": { + "$ref": "#/definitions/ClassDeclaration" + } + }, + { + "if": { + "properties": { + "customElement": { + "const": true + }, + "kind": { + "const": "class", + "type": "string" + } + }, + "required": ["kind", "customElement"] + }, + "then": { + "$ref": "#/definitions/CustomElementDeclaration" + } + } + ], + "properties": { + "kind": { + "enum": ["function", "variable", "mixin", "class"] + } + }, + "required": ["kind"], + "type": "object" + }, + "FunctionDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "function", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "MixinDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "mixin", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "VariableDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "variable", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + } + } +} \ No newline at end of file diff --git a/test/valid-data/discriminator-non-congruent/main.ts b/test/valid-data/discriminator-non-congruent/main.ts new file mode 100644 index 000000000..d1f96e849 --- /dev/null +++ b/test/valid-data/discriminator-non-congruent/main.ts @@ -0,0 +1,40 @@ +export interface FunctionDeclaration { + kind: "function"; + name: string; +} + +export interface VariableDeclaration { + kind: "variable"; + name: string; +} + +export interface MixinDeclaration { + kind: "mixin"; + name: string; +} + +export interface ClassDeclaration { + kind: "class"; + name: string; +} + +// These types don't have the discriminator field at all +export interface SimpleDeclaration { + name: string; +} + +export interface AnotherDeclaration { + name: string; + value: number; +} + +/** + * @discriminator kind + */ +export type Declaration = + | FunctionDeclaration + | VariableDeclaration + | MixinDeclaration + | ClassDeclaration + | SimpleDeclaration + | AnotherDeclaration; \ No newline at end of file diff --git a/test/valid-data/discriminator-non-congruent/schema.json b/test/valid-data/discriminator-non-congruent/schema.json new file mode 100644 index 000000000..f6c2aa4ab --- /dev/null +++ b/test/valid-data/discriminator-non-congruent/schema.json @@ -0,0 +1,174 @@ +{ + "$ref": "#/definitions/Declaration", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AnotherDeclaration": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": ["name", "value"], + "type": "object" + }, + "ClassDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "class", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "Declaration": { + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "function", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/FunctionDeclaration" + } + }, + { + "if": { + "properties": { + "kind": { + "const": "variable", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/VariableDeclaration" + } + }, + { + "if": { + "properties": { + "kind": { + "const": "mixin", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/MixinDeclaration" + } + }, + { + "if": { + "properties": { + "kind": { + "const": "class", + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/ClassDeclaration" + } + }, + { + "if": { + "not": { + "properties": { + "kind": {} + }, + "required": ["kind"] + } + }, + "then": { + "$ref": "#/definitions/SimpleDeclaration" + } + }, + { + "if": { + "not": { + "properties": { + "kind": {} + }, + "required": ["kind"] + } + }, + "then": { + "$ref": "#/definitions/AnotherDeclaration" + } + } + ], + "properties": { + "kind": { + "enum": ["function", "variable", "mixin", "class"] + } + }, + "required": [], + "type": "object" + }, + "FunctionDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "function", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "MixinDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "mixin", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + }, + "SimpleDeclaration": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "VariableDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "variable", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["kind", "name"], + "type": "object" + } + } +} \ No newline at end of file diff --git a/test/valid-data/missing-discriminator/main.ts b/test/valid-data/missing-discriminator/main.ts new file mode 100644 index 000000000..7e79ba67b --- /dev/null +++ b/test/valid-data/missing-discriminator/main.ts @@ -0,0 +1,10 @@ +export interface A { + type: string; +} + +export interface B {} + +/** + * @discriminator type + */ +export type MyType = A | B; diff --git a/test/valid-data/missing-discriminator/schema.json b/test/valid-data/missing-discriminator/schema.json new file mode 100644 index 000000000..db1f4b970 --- /dev/null +++ b/test/valid-data/missing-discriminator/schema.json @@ -0,0 +1,52 @@ +{ + "$ref": "#/definitions/MyType", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "A": { + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + "B": { + "additionalProperties": false, + "type": "object" + }, + "MyType": { + "allOf": [ + { + "if": { + "properties": { + "type": { + "type": "string" + } + } + }, + "then": { + "$ref": "#/definitions/A" + } + }, + { + "if": { + "not": { + "properties": { + "type": {} + }, + "required": ["type"] + } + }, + "then": { + "$ref": "#/definitions/B" + } + } + ], + "properties": {}, + "required": [], + "type": "object" + } + } +} \ No newline at end of file