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
5 changes: 5 additions & 0 deletions .changeset/concrete-base-discriminator-unions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'genoc': patch
---

Concrete discriminator bases now join their own `{Base}Variant` unions. Previously, refs to a base with no `oneOf`/`anyOf` (or listed in its own `mapping`) could name an undeclared union type, a self-mapped base collapsed its children to `never` through conflicting discriminant literals, and mapping-children-only families omitted the base from its own union. Variants of a concrete base now strip the discriminant via `Omit<Base, '$disc'>`, implicit direct-`allOf` children count as variants, and families with no members fall back to bare base names instead of emitting an unused union. Generated output shape changes for specs with concrete discriminator bases: existing variant unions gain the base as a member. Fixes #93.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Integration tests compile generated output with `tsc --strict` to verify type co

**Contracts file** (`*.contracts.ts`): schema types → security scheme types → server variable types → per-operation query/header/body/response/error types → `StreamResponse` class (headers as `Record<string, string>`) → `ErrorResponse` class (headers as `Record<string, string>`) → `ApiError<TStatus, TData>` class → `DefaultApiError<TData>` class → `RequesterFailError`. Also includes per-operation error union types.

Discriminated families (from the discriminator registry in `AnalyzedSpec`): each variant injects its discriminator literal exactly once, at its own named definition; same-family cross-variant inheritance renders `Omit<Sibling, 'Prop'>`; every family always emits a collision-safe `{Base}Variant` union (mapping values ∪ oneOf refs); a `$ref` to a discriminator base resolves to `{Base}Variant` at all sites except inside same-family member definitions (refs to mapping targets stay bare names).
Discriminated families (from the discriminator registry in `AnalyzedSpec`): each variant injects its discriminator literal exactly once, at its own named definition; same-family cross-variant inheritance renders `Omit<Sibling, 'Prop'>`. A concrete base (no `oneOf`/`anyOf`, or listed in its own `mapping`) joins its own family; every family with members emits a collision-safe `{Base}Variant` union (base ∪ mapping values ∪ implicit oneOf/anyOf refs ∪ implicit direct-allOf children, in that order). A `$ref` to the base resolves to `{Base}Variant` everywhere except inside same-family member definitions, where a member base renders via the same-family `Omit` rule and a non-member base keeps its bare name; if a family has no members, base refs fall back to the bare base name and no union is emitted.

**Client file** (`*.client.ts`): imports from contracts file (`ApiError`, `UnspecifiedApiError`, `ErrorResponse`, `StreamResponse`, `RequesterFailError`) → `decorateWithErrors<T, E>()` (attaches `__definedErrors` property) → `Requester` type (returns `TResponse | StreamResponse | ErrorResponse`) → `isDefinedError` type guard (uses `__definedErrors` property for narrowing) → `createClient(requester)` factory → methods with try/catch wrapping `ApiError` throws + `StreamResponse` binary handling. Error codes attached via `decorateWithErrors(fn, [400, ...] as const)`.

Expand Down
117 changes: 109 additions & 8 deletions src/analyzer/discriminator-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,18 @@ export interface DiscriminatorVariantEntry {
isNullable: boolean;
/** Owning family id — the discriminator base's raw component name. */
familyId: string;
/** The exact `$ref` string that produced this entry (mapping value or oneOf/anyOf member ref). */
/** The exact `$ref` string that produced this entry (mapping value, oneOf/anyOf member ref, canonical base ref, or implicit allOf child ref). */
refStr: string;
/** Rename-aware type name for the ref (same derivation as the mapper's `typeNameGenerator`). */
typeName: string;
/**
* True for the family base's own entry — the base participates in its own
* family union (self-mapped, or no `oneOf`/`anyOf` of its own). Consumers:
* same-family-spine base-ref translation routes through the entry seam
* (`Omit<Base, P>`), and the mapper skips the `{ P: string }` placeholder on
* the base's own definition because the literal replaces it.
*/
isBaseMember: boolean;
}

/**
Expand All @@ -130,7 +138,7 @@ export interface DiscriminatorFamily {
propertyName: string;
/** Collision-safe `{Base}Variant` union name (D6 — via the reserved-name machinery). */
variantUnionName: string;
/** Union member type names (rename-aware, deduped; mapping order, then unmapped oneOf/anyOf refs). */
/** Union member type names (rename-aware, deduped; entry insertion order: base, mapping, unmapped oneOf/anyOf refs, implicit allOf children). */
variantUnionMembers: string[];
/** Family entries keyed by the FULL `$ref` string (mapping value or oneOf/anyOf member ref). */
entriesByRef: Map<string, DiscriminatorVariantEntry>;
Expand Down Expand Up @@ -194,9 +202,33 @@ export function buildDiscriminatorRegistry(
};
if (!schemas) return registry;

// Pass 1 — discover families; per family, register mapping entries first
// (D3: mapping key wins), then implicit oneOf/anyOf variants absent from
// the mapping (literal = own const > single-value enum > RAW ref segment).
// Implicit allOf children (issue #93): a schema whose `allOf` references a
// family base DIRECTLY is a variant of that family even when `mapping` and
// the base's own oneOf/anyOf leave it out. Index childName → the set of
// canonical `#/components/schemas/…` refs it allOfs, once, up front —
// grandchildren (allOf a child, not the base) stay out by construction.
const allOfBasesByChild = new Map<string, Set<string>>();
for (const [childName, childSchema] of Object.entries(schemas)) {
let childResolved: SchemaObject;
try {
childResolved = resolver.resolve<SchemaObject>(childSchema as SchemaObject | ReferenceObject);
} catch {
continue;
}
if (!childResolved.allOf) continue;
const bases = new Set<string>();
for (const member of childResolved.allOf) {
if (!isRefObject(member) || !member.$ref.startsWith('#/components/schemas/')) continue;
bases.add(`#/components/schemas/${encodePointerSegment(rawRefSegment(member.$ref))}`);
}
if (bases.size > 0) allOfBasesByChild.set(childName, bases);
}

// Pass 1 — discover families; per family, register the base's own entry
// when it joins its family (self-mapped, or no oneOf/anyOf of its own),
// then mapping entries (D3: mapping key wins), then implicit oneOf/anyOf
// variants absent from the mapping (literal = own const > single-value
// enum > RAW ref segment), then implicit allOf children absent from both.
for (const [name, schema] of Object.entries(schemas)) {
const resolved = resolver.resolve<SchemaObject>(schema as SchemaObject | ReferenceObject);
const disc = resolved.discriminator;
Expand All @@ -213,11 +245,50 @@ export function buildDiscriminatorRegistry(
entriesByRef: new Map(),
};

// Base membership (#93): a CONCRETE base (no oneOf/anyOf of its own) is a
// variant of its own family even when `mapping` omits it — with no other
// members the family would otherwise reference an empty union. A base
// listed in its own mapping joins too (its key wins the literal, D3).
// Insertion order matters: the base entry lands FIRST so union members
// read `{Base} | <mapped…> | <implicit…>`.
const baseHasOwnUnion = (resolved.oneOf?.length ?? 0) > 0 || (resolved.anyOf?.length ?? 0) > 0;
const selfMappingKey = disc.mapping
? Object.entries(disc.mapping).find(([, refStr]) => refStr === family.baseRefStr)?.[0]
: undefined;
if (selfMappingKey !== undefined || !baseHasOwnUnion) {
const ownBaseLiteral = resolveOwnDiscriminatorLiteral(resolved, disc.propertyName, resolver);
if (
selfMappingKey !== undefined &&
ownBaseLiteral !== undefined &&
ownBaseLiteral.value !== selfMappingKey
) {
warn(
`Warning: Discriminator mapping key "${selfMappingKey}" for "${family.baseRefStr}" conflicts with its own declared ${ownBaseLiteral.source} literal "${ownBaseLiteral.value}"; the mapping key wins.\n`
);
}
family.entriesByRef.set(family.baseRefStr, {
propertyName: disc.propertyName,
// Base literal precedence: self-mapping key > own const/enum > raw
// component name (the runtime value, NOT the sanitized type name).
literalValue: escapeStringLiteral(selfMappingKey ?? ownBaseLiteral?.value ?? name),
isNamed: true, // the base is by definition an emitted component schema
isUnion: baseHasOwnUnion,
isNullable:
resolved.nullable === true ||
(Array.isArray(resolved.type) ? resolved.type.includes('null') : false),
isBaseMember: true,
familyId,
refStr: family.baseRefStr,
typeName: family.baseTypeName,
});
}

const mappedRefStrs = new Set<string>();
if (disc.mapping) {
for (const [mappingKey, refStr] of Object.entries(disc.mapping)) {
if (mappedRefStrs.has(refStr)) continue; // two keys → same target: first key wins
mappedRefStrs.add(refStr);
if (family.entriesByRef.has(refStr)) continue; // base self-mapping — the base entry already carries it
const target = resolveTargetSchema(resolver, refStr);
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
if (own !== undefined && own.value !== mappingKey) {
Expand All @@ -234,6 +305,7 @@ export function buildDiscriminatorRegistry(
isNullable:
target?.nullable === true ||
(Array.isArray(target?.type) ? target.type.includes('null') : false),
isBaseMember: false,
familyId,
refStr,
typeName: renameAwareTypeName(renameMap, rawSegment),
Expand All @@ -247,7 +319,7 @@ export function buildDiscriminatorRegistry(
if (isRefObject(member)) implicitRefs.push(member.$ref);
}
for (const refStr of implicitRefs) {
if (mappedRefStrs.has(refStr)) continue; // already registered via mapping
if (mappedRefStrs.has(refStr) || family.entriesByRef.has(refStr)) continue; // already registered
const target = resolveTargetSchema(resolver, refStr);
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
const rawSegment = rawRefSegment(refStr);
Expand All @@ -262,19 +334,48 @@ export function buildDiscriminatorRegistry(
isNullable:
target?.nullable === true ||
(Array.isArray(target?.type) ? target.type.includes('null') : false),
isBaseMember: false,
familyId,
refStr,
typeName: renameAwareTypeName(renameMap, rawSegment),
};
family.entriesByRef.set(refStr, entry);
}

// Implicit allOf children (#93): direct `allOf` refs to the base count as
// variants even when `mapping` leaves them out; anything already present
// (mapping target, implicit oneOf/anyOf ref, the base itself) is skipped,
// so children land in component order after the mapped/implicit members.
for (const [childName, bases] of allOfBasesByChild) {
if (!bases.has(family.baseRefStr)) continue;
const childRefStr = `#/components/schemas/${encodePointerSegment(childName)}`;
if (family.entriesByRef.has(childRefStr)) continue;
const target = resolveTargetSchema(resolver, childRefStr);
const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
const entry: DiscriminatorVariantEntry = {
propertyName: disc.propertyName,
// Same D3 fallback chain as unmapped oneOf/anyOf variants.
literalValue: escapeStringLiteral(own?.value ?? childName),
isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, childName)),
isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
isNullable:
target?.nullable === true ||
(Array.isArray(target?.type) ? target.type.includes('null') : false),
isBaseMember: false,
familyId,
refStr: childRefStr,
typeName: renameAwareTypeName(renameMap, childName),
};
family.entriesByRef.set(childRefStr, entry);
}

registry.families.set(familyId, family);
}

// Pass 2 — flat lookups + per-family name index + union members
// (mapping order, then unmapped oneOf/anyOf refs; deduped by refStr and
// by typeName — alias refs to one emitted name collapse).
// (entry insertion order: base, then mapping, then unmapped oneOf/anyOf
// refs, then implicit allOf children; deduped by refStr and by typeName
// — alias refs to one emitted name collapse).
for (const family of registry.families.values()) {
const memberNames: string[] = [];
const seenNames = new Set<string>();
Expand Down
19 changes: 18 additions & 1 deletion src/analyzer/schema-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,16 @@ interface SpineContext {
familyId: string;
propertyName: string;
literalValue: string;
/** The spine's entry is the family base itself (#93) — its definition's `{ P: string }` placeholder is replaced by the literal. */
isBaseMember: boolean;
}

function spineForEntry(entry: DiscriminatorVariantEntry): SpineContext {
return {
familyId: entry.familyId,
propertyName: entry.propertyName,
literalValue: entry.literalValue,
isBaseMember: entry.isBaseMember,
};
}

Expand Down Expand Up @@ -471,9 +474,21 @@ export class SchemaMapper {

const baseFamily = registry.baseRefs.get(refStr) ?? registry.baseRefs.get(canonical);
if (baseFamily) {
const baseEntry = baseFamily.entriesByRef.get(baseFamily.baseRefStr);
if (spine && spine.familyId === baseFamily.familyId && baseEntry) {
// Member base inside its own family spine (#93): the base joins as a
// variant through the entry seam — `Omit<Base, P>` — so the child's
// literal append never collides with the base's own.
return this.mapSameFamilySpineRef(baseEntry, context, visited, indent);
}
if (spine && spine.familyId === baseFamily.familyId) {
return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
}
// Empty family (#93): no members at all — refs fall back to the bare
// base name instead of an undeclared `{Base}Variant` union.
if (baseFamily.variantUnionMembers.length === 0) {
return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
}
return { tsType: baseFamily.variantUnionName, imports: [baseFamily.variantUnionName] };
}

Expand Down Expand Up @@ -872,7 +887,9 @@ export class SchemaMapper {
const memberLines: string[] = [];
const allImports: string[] = [];

if (schema.discriminator) {
if (schema.discriminator && !spine?.isBaseMember) {
// A member base's placeholder (`"${discPropName}": string;`) is skipped:
// its literal append replaces the declared property outright (#93).
const discPropName = schema.discriminator.propertyName;
const discIndex = filteredPropNames.indexOf(discPropName);
if (discIndex !== -1) {
Expand Down
77 changes: 77 additions & 0 deletions tests/fixtures/discriminator-concrete-base-empty-31.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{
"openapi": "3.1.0",
"info": {
"title": "Concrete discriminator base — 3.1 parity and empty family",
"version": "1.0.0"
},
"paths": {
"/api/Entities/Find": {
"get": {
"parameters": [
{ "name": "take", "in": "query", "schema": { "type": "integer" } },
{ "name": "skip", "in": "query", "schema": { "type": "integer" } }
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Entity" } }
}
}
}
}
}
},
"/api/Orphans": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Orphan" }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Entity": {
"type": "object",
"required": ["$type", "id", "name"],
"properties": {
"$type": { "type": "string" },
"id": { "type": "string" },
"name": { "type": "string" }
},
"discriminator": {
"propertyName": "$type",
"mapping": {
"Entity": "#/components/schemas/Entity",
"EntityDetails": "#/components/schemas/EntityDetails"
}
}
},
"EntityDetails": {
"allOf": [
{ "$ref": "#/components/schemas/Entity" },
{
"type": "object",
"required": ["shortName"],
"properties": { "shortName": { "type": "string" } }
}
]
},
"Orphan": {
"oneOf": [{ "type": "object" }],
"discriminator": {
"propertyName": "kind"
}
}
}
}
}
Loading
Loading