Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,78 @@ fs.writeFile(outputPath, schemaString, (err) => {
- functions
- `Promise<T>` 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

Expand Down
188 changes: 163 additions & 25 deletions src/TypeFormatter/UnionTypeFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,52 +37,190 @@ 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,
});
}

const kindValues = kindDefinitions
.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<any, { type: BaseType; definition: Definition }[]>();
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);
Expand Down
7 changes: 1 addition & 6 deletions src/Utils/narrowType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 5 additions & 4 deletions test/invalid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
28 changes: 28 additions & 0 deletions test/unit/UnionTypeFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
9 changes: 9 additions & 0 deletions test/valid-data-annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
);
});
Loading