Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .changeset/discriminated-union-discriminator.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions libs/schema-json/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
84 changes: 84 additions & 0 deletions libs/schema-json/src/toJsonSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
46 changes: 44 additions & 2 deletions libs/schema-json/src/toJsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,52 @@ 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 };
// Only emit mapping when every option resolved to a $ref
// and every discriminator value can be populated.
const mapping: Record<string, string> = {};
let allRefsMapped = options.length > 0;
for (let i = 0; i < options.length; i++) {
const ref = converted[i]['$ref'];
if (typeof ref !== 'string') {
allRefsMapped = false;
break;
}

const oi = options[i].introspect() as any;
const props:
| Record<string, SchemaBuilder<any, any, any>>
| 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 (allRefsMapped) disc['mapping'] = mapping;
out['discriminator'] = disc;
}

return out;
}

default:
Expand Down
9 changes: 8 additions & 1 deletion libs/schema-json/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
};
[k: string]: unknown;
}
| { readonly allOf: readonly JsonSchemaNode[]; [k: string]: unknown }
| Record<string, never>;

Expand Down
30 changes: 11 additions & 19 deletions libs/schema/src/builders/SchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<name>'` 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
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions libs/schema/src/builders/UnionSchemaBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion libs/schema/src/builders/UnionSchemaBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
23 changes: 23 additions & 0 deletions libs/server-openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: '#/components/schemas/Cat', dog: '#/components/schemas/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:
Expand Down
Loading
Loading