From b8f12856d50207c98741c8c6cdc042eed09d64b1 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 08:38:14 +0000 Subject: [PATCH 1/3] feat: add support for OpenAPI discriminator in discriminated unions --- .../discriminated-union-discriminator.md | 23 +++++ libs/schema-json/README.md | 27 ++++++ libs/schema-json/src/toJsonSchema.test.ts | 84 +++++++++++++++++++ libs/schema-json/src/toJsonSchema.ts | 38 ++++++++- libs/schema-json/src/types.ts | 9 +- libs/schema/src/builders/SchemaBuilder.ts | 30 +++---- .../src/builders/UnionSchemaBuilder.test.ts | 12 +++ .../schema/src/builders/UnionSchemaBuilder.ts | 8 +- libs/server-openapi/README.md | 23 +++++ .../src/generateOpenApiSpec.test.ts | 69 ++++++++++++++- website/app/schema-json/page.tsx | 9 ++ .../schema/sections/discriminated-unions.tsx | 35 ++++++++ website/app/server-openapi/page.tsx | 7 ++ 13 files changed, 350 insertions(+), 24 deletions(-) create mode 100644 .changeset/discriminated-union-discriminator.md diff --git a/.changeset/discriminated-union-discriminator.md b/.changeset/discriminated-union-discriminator.md new file mode 100644 index 0000000..8a6c608 --- /dev/null +++ b/.changeset/discriminated-union-discriminator.md @@ -0,0 +1,23 @@ +--- +'@cleverbrush/schema': minor +'@cleverbrush/schema-json': minor +--- + +Emit the OpenAPI `discriminator` keyword for discriminated unions + +**`@cleverbrush/schema`** — `UnionSchemaBuilder.introspect()` now exposes +`discriminatorPropertyName: string | undefined`. When all union branches are +object schemas sharing a required property with unique literal values, this +field returns the property name (e.g. `'type'`). Otherwise it is `undefined`. + +**`@cleverbrush/schema-json`** — `toJsonSchema()` emits +`discriminator: { propertyName }` alongside `anyOf` for discriminated unions. +When a `nameResolver` is provided and union branches resolve to `$ref` pointers, +a `mapping` object is also emitted mapping each discriminator value to its +corresponding `$ref` path. + +This enables code-generation consumers (openapi-generator, orval, etc.) to +produce proper tagged union types from the generated OpenAPI specs. + +`@cleverbrush/server-openapi` benefits automatically — no code changes needed +in that package. diff --git a/libs/schema-json/README.md b/libs/schema-json/README.md index be51e35..8d88e8b 100644 --- a/libs/schema-json/README.md +++ b/libs/schema-json/README.md @@ -115,6 +115,7 @@ const schema = fromJsonSchema(S); // ObjectSchemaBuilder<{ x: NumberSchemaBuilde | `const` | literal builder (`.equals(...)`) | | `enum` | `union(…)` of const builders | | `anyOf` | `union(…)` of sub-builders | +| `anyOf` + `discriminator` | auto-emitted for discriminated `union()` branches (see below) | | `allOf` | not supported — falls back to `any()` | | `minLength` / `maxLength` | `.minLength()` / `.maxLength()` | | `pattern` | `.matches(regex)` (invalid patterns silently ignored) | @@ -153,6 +154,32 @@ function toJsonSchema( Descriptions set via `.describe(text)` are emitted as the `description` field on the corresponding JSON Schema node (including nested object properties). +#### Discriminated unions + +When a `union()` is a **discriminated union** — all branches are objects sharing a required property with unique literal values — `toJsonSchema()` automatically emits the `discriminator` keyword alongside `anyOf`: + +```ts +const schema = union( + object({ type: string('cat'), name: string() }) +).or( + object({ type: string('dog'), breed: string() }) +); + +toJsonSchema(schema, { $schema: false }); +// { +// anyOf: [ { ... type: { const: 'cat' } ... }, { ... type: { const: 'dog' } ... } ], +// discriminator: { propertyName: 'type' } +// } +``` + +When a `nameResolver` is provided and union branches resolve to `$ref` pointers, a `mapping` is also emitted: + +```ts +// discriminator: { propertyName: 'type', mapping: { cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog' } } +``` + +This enables code-generation tools (openapi-generator, orval, etc.) to produce proper tagged union types. + #### `ToJsonSchemaOptions` | Option | Type | Default | Description | diff --git a/libs/schema-json/src/toJsonSchema.test.ts b/libs/schema-json/src/toJsonSchema.test.ts index c7c3848..1af9840 100644 --- a/libs/schema-json/src/toJsonSchema.test.ts +++ b/libs/schema-json/src/toJsonSchema.test.ts @@ -220,6 +220,90 @@ test('toJsonSchema - 28: mixed union → anyOf', () => { }); }); +// --------------------------------------------------------------------------- +// discriminated unions → discriminator keyword +// --------------------------------------------------------------------------- + +test('toJsonSchema - 28a: discriminated union → anyOf + discriminator', () => { + const schema = union(object({ type: string('cat'), name: string() })).or( + object({ type: string('dog'), breed: string() }) + ); + const result = toJsonSchema(schema, { $schema: false }); + expect(result).toEqual({ + anyOf: [ + { + type: 'object', + properties: { + type: { const: 'cat' }, + name: { type: 'string' } + }, + required: ['type', 'name'], + additionalProperties: false + }, + { + type: 'object', + properties: { + type: { const: 'dog' }, + breed: { type: 'string' } + }, + required: ['type', 'breed'], + additionalProperties: false + } + ], + discriminator: { propertyName: 'type' } + }); +}); + +test('toJsonSchema - 28b: non-discriminated union → no discriminator key', () => { + const schema = union(string()).or(number().isFloat()); + const result = toJsonSchema(schema, { $schema: false }); + expect(result).not.toHaveProperty('discriminator'); +}); + +test('toJsonSchema - 28c: nullable discriminated union → discriminator preserved', () => { + const schema = union(object({ kind: string('a'), x: number() })) + .or(object({ kind: string('b'), y: number() })) + .nullable(); + const result = toJsonSchema(schema, { $schema: false }); + expect(result['discriminator']).toEqual({ propertyName: 'kind' }); + // anyOf should include { type: 'null' } for nullable + const anyOf = result['anyOf'] as any[]; + expect(anyOf.some((o: any) => o.type === 'null')).toBe(true); +}); + +test('toJsonSchema - 28d: discriminated union with nameResolver → mapping', () => { + const catSchema = object({ + type: string('cat'), + name: string() + }).schemaName('Cat'); + const dogSchema = object({ + type: string('dog'), + breed: string() + }).schemaName('Dog'); + const schema = union(catSchema).or(dogSchema); + + const resolver = (s: any) => { + const info = s.introspect(); + return info.schemaName ?? null; + }; + + const result = toJsonSchema(schema, { + $schema: false, + nameResolver: resolver + }); + expect(result['discriminator']).toEqual({ + propertyName: 'type', + mapping: { + cat: '#/components/schemas/Cat', + dog: '#/components/schemas/Dog' + } + }); + // anyOf entries should be $refs + const anyOf = result['anyOf'] as any[]; + expect(anyOf[0]).toEqual({ $ref: '#/components/schemas/Cat' }); + expect(anyOf[1]).toEqual({ $ref: '#/components/schemas/Dog' }); +}); + // --------------------------------------------------------------------------- // any // --------------------------------------------------------------------------- diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index c99d4ab..8e9d2e9 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -163,10 +163,44 @@ function convertNodeInner( } } if (allConst) return { ...readOnly, enum: enumValues }; - return { + + const converted = options.map(o => convertNode(o, resolver)); + const out: Out = { ...readOnly, - anyOf: options.map(o => convertNode(o, resolver)) + anyOf: converted }; + + // Emit discriminator keyword for discriminated unions + const discriminatorProp: string | undefined = + info.discriminatorPropertyName; + if (discriminatorProp) { + const disc: Out = { propertyName: discriminatorProp }; + // Build mapping when any option resolved to a $ref + const mapping: Record = {}; + let hasRef = false; + for (let i = 0; i < options.length; i++) { + const ref = converted[i]['$ref']; + if (typeof ref === 'string') { + const oi = options[i].introspect() as any; + const props: + | Record> + | undefined = oi.properties; + if (props?.[discriminatorProp]) { + const propInfo = props[ + discriminatorProp + ].introspect() as any; + if (propInfo.equalsTo !== undefined) { + mapping[String(propInfo.equalsTo)] = ref; + hasRef = true; + } + } + } + } + if (hasRef) disc['mapping'] = mapping; + out['discriminator'] = disc; + } + + return out; } default: diff --git a/libs/schema-json/src/types.ts b/libs/schema-json/src/types.ts index 7ccc1e9..ad79f78 100644 --- a/libs/schema-json/src/types.ts +++ b/libs/schema-json/src/types.ts @@ -55,7 +55,14 @@ export type JsonSchemaNode = } | { readonly const: unknown; [k: string]: unknown } | { readonly enum: readonly unknown[]; [k: string]: unknown } - | { readonly anyOf: readonly JsonSchemaNode[]; [k: string]: unknown } + | { + readonly anyOf: readonly JsonSchemaNode[]; + readonly discriminator?: { + readonly propertyName: string; + readonly mapping?: Readonly>; + }; + [k: string]: unknown; + } | { readonly allOf: readonly JsonSchemaNode[]; [k: string]: unknown } | Record; diff --git a/libs/schema/src/builders/SchemaBuilder.ts b/libs/schema/src/builders/SchemaBuilder.ts index 4d62be2..19ad7d3 100644 --- a/libs/schema/src/builders/SchemaBuilder.ts +++ b/libs/schema/src/builders/SchemaBuilder.ts @@ -1411,7 +1411,7 @@ export abstract class SchemaBuilder< */ description: this.#description, /** - * The component name attached to this schema via `.schemaName()`, + * The logical name attached to this schema via `.schemaName()`, * or `undefined` if none was set. */ schemaName: this.#schemaName, @@ -1579,23 +1579,17 @@ export abstract class SchemaBuilder< } /** - * Attaches a component name to this schema for OpenAPI `components/schemas` - * registration. + * Attaches a logical name to this schema instance. * - * When a named schema is passed to `generateOpenApiSpec()` from - * `@cleverbrush/server-openapi`, it is collected into - * `components.schemas[name]` and every occurrence in the spec is replaced - * with a `$ref: '#/components/schemas/'` pointer instead of being - * inlined in full. + * The name is purely metadata — it has no effect on validation. It is + * accessible via `.introspect().schemaName` and can be consumed by any + * tool that introspects schemas at runtime, such as OpenAPI spec + * generators, documentation tools, form libraries, or code generators. * - * **Naming rules** - * - Names must be unique across the spec. Registering two *different* - * schema instances under the same name throws at generation time. - * - Re-using the same constant (same object reference) in multiple - * endpoints is fine — it is detected and deduplicated automatically. - * - * The name has no effect on validation and is not emitted by - * `toJsonSchema()` from `@cleverbrush/schema-json`. + * **Uniqueness** is the responsibility of the consuming tool. Passing the + * same constant (same object reference) to multiple consumers is always + * safe; how conflicts between different instances with the same name are + * handled depends on the tool. * * @example * ```ts @@ -1606,9 +1600,7 @@ export abstract class SchemaBuilder< * name: string(), * }).schemaName('User'); * - * // Both endpoints share the same constant → single components.schemas entry - * const GetUser = endpoint.get('/users/:id').returns(UserSchema); - * const ListUsers = endpoint.get('/users').returns(array(UserSchema)); + * UserSchema.introspect().schemaName; // 'User' * ``` */ public schemaName(name: string): this { diff --git a/libs/schema/src/builders/UnionSchemaBuilder.test.ts b/libs/schema/src/builders/UnionSchemaBuilder.test.ts index 974ddb9..e0c2ac4 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.test.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.test.ts @@ -710,6 +710,18 @@ test('clearDefault - removes the default value', () => { // Discriminator detection failure cases (lines 242-272) // --------------------------------------------------------------------------- +test('introspect: discriminated union exposes discriminatorPropertyName', () => { + const schema = union(object({ type: string('cat'), name: string() })).or( + object({ type: string('dog'), breed: string() }) + ); + expect(schema.introspect().discriminatorPropertyName).toBe('type'); +}); + +test('introspect: non-discriminated union has discriminatorPropertyName undefined', () => { + const schema = union(string()).or(number()); + expect(schema.introspect().discriminatorPropertyName).toBeUndefined(); +}); + test('discriminator: missing key in one branch → falls back to linear scan (line 242)', () => { // First branch has "type", second branch does NOT → isDiscriminator=false const schema = union(object({ type: string('cat'), name: string() })).or( diff --git a/libs/schema/src/builders/UnionSchemaBuilder.ts b/libs/schema/src/builders/UnionSchemaBuilder.ts index b8ecd92..0560e4c 100644 --- a/libs/schema/src/builders/UnionSchemaBuilder.ts +++ b/libs/schema/src/builders/UnionSchemaBuilder.ts @@ -289,7 +289,13 @@ export class UnionSchemaBuilder< /** * Array of schemas participating in the union. */ - options: this.#options + options: this.#options, + /** + * When the union is a discriminated union (all branches are objects + * sharing a required property with unique literal values), this is + * the name of that property. `undefined` otherwise. + */ + discriminatorPropertyName: this.#discriminatorKey ?? undefined }; } diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index 6ddaa08..ecb81de 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -157,6 +157,29 @@ registry.entries(); // IterableIterator<[name, SchemaBuilder]> registry.isEmpty; // boolean ``` +## Discriminated Unions + +When a request body, response, or parameter schema is a **discriminated union** — all branches are objects sharing a required property with unique literal values — the generated spec automatically includes the OpenAPI `discriminator` keyword alongside `anyOf`. + +If the union branches use `.schemaName()` and are extracted as `$ref` components, the `discriminator` also includes a `mapping` from each literal value to its `$ref` path: + +```ts +const Cat = object({ type: string('cat'), name: string() }).schemaName('Cat'); +const Dog = object({ type: string('dog'), breed: string() }).schemaName('Dog'); +const PetBody = union(Cat).or(Dog); + +const CreatePet = endpoint.post('/api/pets').body(PetBody); + +// Generated spec: +// requestBody.content['application/json'].schema: +// { +// anyOf: [{ $ref: '#/components/schemas/Cat' }, { $ref: '#/components/schemas/Dog' }], +// discriminator: { propertyName: 'type', mapping: { cat: '…/Cat', dog: '…/Dog' } } +// } +``` + +Code generators like openapi-generator and orval use the `discriminator` to produce proper tagged union types. + ## Authentication & Security Schemes Pass the server's `AuthenticationConfig` to automatically generate `securitySchemes` and per-operation `security` arrays: diff --git a/libs/server-openapi/src/generateOpenApiSpec.test.ts b/libs/server-openapi/src/generateOpenApiSpec.test.ts index fae8dac..388c8c5 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.test.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.test.ts @@ -1,4 +1,4 @@ -import { boolean, number, object, string } from '@cleverbrush/schema'; +import { boolean, number, object, string, union } from '@cleverbrush/schema'; import type { EndpointMetadata, EndpointRegistration @@ -628,3 +628,70 @@ describe('generateOpenApiSpec — $ref deduplication', () => { expect(Object.keys(schemas)).toEqual(['User']); }); }); + +// --------------------------------------------------------------------------- +// Discriminated union → discriminator keyword +// --------------------------------------------------------------------------- + +describe('discriminated union discriminator keyword', () => { + it('emits discriminator on inline discriminated union body schema', () => { + const bodySchema = union( + object({ type: string('cat'), name: string() }) + ).or(object({ type: string('dog'), breed: string() })); + + const spec = generateOpenApiSpec( + makeOptions([ + makeReg({ + method: 'POST', + bodySchema + }) + ]) + ); + + const body = (spec['paths'] as any)['/api/items']['post'][ + 'requestBody' + ]['content']['application/json']['schema']; + expect(body['discriminator']).toEqual({ propertyName: 'type' }); + expect(body['anyOf']).toBeDefined(); + }); + + it('emits discriminator with mapping when branches are named schemas', () => { + const CatSchema = object({ + type: string('cat'), + name: string() + }).schemaName('Cat'); + const DogSchema = object({ + type: string('dog'), + breed: string() + }).schemaName('Dog'); + const bodySchema = union(CatSchema).or(DogSchema); + + const spec = generateOpenApiSpec( + makeOptions([ + makeReg({ + method: 'POST', + bodySchema + }) + ]) + ); + + const body = (spec['paths'] as any)['/api/items']['post'][ + 'requestBody' + ]['content']['application/json']['schema']; + expect(body['discriminator']).toEqual({ + propertyName: 'type', + mapping: { + cat: '#/components/schemas/Cat', + dog: '#/components/schemas/Dog' + } + }); + expect(body['anyOf']).toEqual([ + { $ref: '#/components/schemas/Cat' }, + { $ref: '#/components/schemas/Dog' } + ]); + // Components should contain both schemas + const schemas = (spec['components'] as any)['schemas']; + expect(schemas['Cat']).toBeDefined(); + expect(schemas['Dog']).toBeDefined(); + }); +}); diff --git a/website/app/schema-json/page.tsx b/website/app/schema-json/page.tsx index 8d3f4fa..2ca9ed9 100644 --- a/website/app/schema-json/page.tsx +++ b/website/app/schema-json/page.tsx @@ -230,6 +230,15 @@ const result = UserSchema.validate({ union(…) / fallback + + + discriminator + + + auto-emitted for discriminated{' '} + union() branches + + minLength /{' '} diff --git a/website/app/schema/sections/discriminated-unions.tsx b/website/app/schema/sections/discriminated-unions.tsx index ddc11b3..3db8aa9 100644 --- a/website/app/schema/sections/discriminated-unions.tsx +++ b/website/app/schema/sections/discriminated-unions.tsx @@ -126,6 +126,41 @@ type Schedule = InferType; z.discriminatedUnion(), but without any extra API surface.

+ +

JSON Schema & OpenAPI

+

+ When you convert a discriminated union to JSON Schema via{' '} + toJsonSchema() (or use it in a{' '} + @cleverbrush/server-openapi endpoint), the{' '} + discriminator keyword is emitted automatically + alongside anyOf. Code generators like + openapi-generator and orval use this to produce proper tagged + union types. +

+
+                
+            
+

+ If the union branches use .schemaName() and are + extracted as $ref components, the discriminator + also includes a mapping from each literal value to + its $ref path. +

); } diff --git a/website/app/server-openapi/page.tsx b/website/app/server-openapi/page.tsx index f124a57..0023955 100644 --- a/website/app/server-openapi/page.tsx +++ b/website/app/server-openapi/page.tsx @@ -71,6 +71,13 @@ export default function ServerOpenApiPage() { auth schemes become securitySchemes{' '} automatically. +
  • + Discriminated unions — the OpenAPI{' '} + discriminator keyword is emitted + automatically for tagged union schemas, enabling + code generators (openapi-generator, orval) to + produce proper typed variants. +
  • serveOpenApi() From 67a77c6ca7774575ec14506b21c7807e39420d49 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 11:46:06 +0300 Subject: [PATCH 2/3] Update libs/schema-json/src/toJsonSchema.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- libs/schema-json/src/toJsonSchema.ts | 42 +++++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/libs/schema-json/src/toJsonSchema.ts b/libs/schema-json/src/toJsonSchema.ts index 8e9d2e9..f2c6c7d 100644 --- a/libs/schema-json/src/toJsonSchema.ts +++ b/libs/schema-json/src/toJsonSchema.ts @@ -175,28 +175,36 @@ function convertNodeInner( info.discriminatorPropertyName; if (discriminatorProp) { const disc: Out = { propertyName: discriminatorProp }; - // Build mapping when any option resolved to a $ref + // Only emit mapping when every option resolved to a $ref + // and every discriminator value can be populated. const mapping: Record = {}; - let hasRef = false; + let allRefsMapped = options.length > 0; for (let i = 0; i < options.length; i++) { const ref = converted[i]['$ref']; - if (typeof ref === 'string') { - const oi = options[i].introspect() as any; - const props: - | Record> - | undefined = oi.properties; - if (props?.[discriminatorProp]) { - const propInfo = props[ - discriminatorProp - ].introspect() as any; - if (propInfo.equalsTo !== undefined) { - mapping[String(propInfo.equalsTo)] = ref; - hasRef = true; - } - } + if (typeof ref !== 'string') { + allRefsMapped = false; + break; } + + const oi = options[i].introspect() as any; + const props: + | Record> + | undefined = oi.properties; + const discriminatorSchema = props?.[discriminatorProp]; + if (!discriminatorSchema) { + allRefsMapped = false; + break; + } + + const propInfo = discriminatorSchema.introspect() as any; + if (propInfo.equalsTo === undefined) { + allRefsMapped = false; + break; + } + + mapping[String(propInfo.equalsTo)] = ref; } - if (hasRef) disc['mapping'] = mapping; + if (allRefsMapped) disc['mapping'] = mapping; out['discriminator'] = disc; } From 997b880e0e890a37e13881daa7916b3cf4693ba4 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Wed, 15 Apr 2026 11:46:25 +0300 Subject: [PATCH 3/3] Update libs/server-openapi/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- libs/server-openapi/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/server-openapi/README.md b/libs/server-openapi/README.md index ecb81de..1510953 100644 --- a/libs/server-openapi/README.md +++ b/libs/server-openapi/README.md @@ -174,7 +174,7 @@ const CreatePet = endpoint.post('/api/pets').body(PetBody); // requestBody.content['application/json'].schema: // { // anyOf: [{ $ref: '#/components/schemas/Cat' }, { $ref: '#/components/schemas/Dog' }], -// discriminator: { propertyName: 'type', mapping: { cat: '…/Cat', dog: '…/Dog' } } +// discriminator: { propertyName: 'type', mapping: { cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog' } } // } ```