diff --git a/.changeset/concrete-base-discriminator-unions.md b/.changeset/concrete-base-discriminator-unions.md
new file mode 100644
index 0000000..3406a36
--- /dev/null
+++ b/.changeset/concrete-base-discriminator-unions.md
@@ -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`, 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.
diff --git a/AGENTS.md b/AGENTS.md
index 4eb4d7e..b27ac82 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`) → `ErrorResponse` class (headers as `Record`) → `ApiError` class → `DefaultApiError` 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`; 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`. 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()` (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)`.
diff --git a/src/analyzer/discriminator-registry.ts b/src/analyzer/discriminator-registry.ts
index 7e021af..00957e6 100644
--- a/src/analyzer/discriminator-registry.ts
+++ b/src/analyzer/discriminator-registry.ts
@@ -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`), and the mapper skips the `{ P: string }` placeholder on
+ * the base's own definition because the literal replaces it.
+ */
+ isBaseMember: boolean;
}
/**
@@ -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;
@@ -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>();
+ for (const [childName, childSchema] of Object.entries(schemas)) {
+ let childResolved: SchemaObject;
+ try {
+ childResolved = resolver.resolve(childSchema as SchemaObject | ReferenceObject);
+ } catch {
+ continue;
+ }
+ if (!childResolved.allOf) continue;
+ const bases = new Set();
+ 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(schema as SchemaObject | ReferenceObject);
const disc = resolved.discriminator;
@@ -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} | | `.
+ 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();
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) {
@@ -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),
@@ -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);
@@ -262,6 +334,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),
@@ -269,12 +342,40 @@ export function buildDiscriminatorRegistry(
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();
diff --git a/src/analyzer/schema-mapper.ts b/src/analyzer/schema-mapper.ts
index a65b428..315e266 100644
--- a/src/analyzer/schema-mapper.ts
+++ b/src/analyzer/schema-mapper.ts
@@ -100,6 +100,8 @@ 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 {
@@ -107,6 +109,7 @@ function spineForEntry(entry: DiscriminatorVariantEntry): SpineContext {
familyId: entry.familyId,
propertyName: entry.propertyName,
literalValue: entry.literalValue,
+ isBaseMember: entry.isBaseMember,
};
}
@@ -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` — 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] };
}
@@ -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) {
diff --git a/tests/fixtures/discriminator-concrete-base-empty-31.json b/tests/fixtures/discriminator-concrete-base-empty-31.json
new file mode 100644
index 0000000..538a676
--- /dev/null
+++ b/tests/fixtures/discriminator-concrete-base-empty-31.json
@@ -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"
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/discriminator-concrete-base-entity.json b/tests/fixtures/discriminator-concrete-base-entity.json
new file mode 100644
index 0000000..5a24189
--- /dev/null
+++ b/tests/fixtures/discriminator-concrete-base-entity.json
@@ -0,0 +1,75 @@
+{
+ "openapi": "3.0.3",
+ "info": { "title": "Concrete discriminator base — Entity 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/Entities/{entityId}": {
+ "get": {
+ "parameters": [
+ {
+ "name": "entityId",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "ok",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/EntityDetails" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "Entity": {
+ "type": "object",
+ "required": ["$type", "id", "name"],
+ "properties": {
+ "$type": { "type": "string" },
+ "id": { "type": "string" },
+ "name": { "type": "string" }
+ },
+ "discriminator": {
+ "propertyName": "$type",
+ "mapping": {
+ "EntityDetails": "#/components/schemas/EntityDetails"
+ }
+ }
+ },
+ "EntityDetails": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Entity" },
+ {
+ "type": "object",
+ "required": ["shortName"],
+ "properties": { "shortName": { "type": "string" } }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/discriminator-concrete-base-oas.json b/tests/fixtures/discriminator-concrete-base-oas.json
new file mode 100644
index 0000000..7339a5b
--- /dev/null
+++ b/tests/fixtures/discriminator-concrete-base-oas.json
@@ -0,0 +1,53 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Concrete discriminator base — OAS Pet example (no mapping)",
+ "version": "1.0.0"
+ },
+ "paths": {
+ "/pets": {
+ "get": {
+ "responses": {
+ "200": {
+ "description": "ok",
+ "content": {
+ "application/json": {
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Pet" } }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "Pet": {
+ "type": "object",
+ "required": ["name", "petType"],
+ "properties": { "name": { "type": "string" }, "petType": { "type": "string" } },
+ "discriminator": { "propertyName": "petType" }
+ },
+ "Cat": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Pet" },
+ {
+ "type": "object",
+ "required": ["huntingSkill"],
+ "properties": { "huntingSkill": { "type": "string" } }
+ }
+ ]
+ },
+ "Dog": {
+ "allOf": [
+ { "$ref": "#/components/schemas/Pet" },
+ {
+ "type": "object",
+ "required": ["packSize"],
+ "properties": { "packSize": { "type": "integer" } }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/discriminator-concrete-base-selfmap.json b/tests/fixtures/discriminator-concrete-base-selfmap.json
new file mode 100644
index 0000000..2dd597b
--- /dev/null
+++ b/tests/fixtures/discriminator-concrete-base-selfmap.json
@@ -0,0 +1,79 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Concrete discriminator base — Entity family, self-mapped base",
+ "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/Entities/{entityId}": {
+ "get": {
+ "parameters": [
+ {
+ "name": "entityId",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "ok",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/EntityDetails" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "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" } }
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/tests/integration/__snapshots__/discriminator-allof.test.ts.snap b/tests/integration/__snapshots__/discriminator-allof.test.ts.snap
index 41d1fbc..ea6804c 100644
--- a/tests/integration/__snapshots__/discriminator-allof.test.ts.snap
+++ b/tests/integration/__snapshots__/discriminator-allof.test.ts.snap
@@ -80,20 +80,19 @@ export {
} from 'genoc/runtime';
export type Pet = {
- "$type": string;
name?: string;
age?: number;
-};
+} & { $type: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: string;
} & { $type: 'Cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: string;
} & { $type: 'Dog' };
-export type PetVariant = Cat | Dog;
+export type PetVariant = Pet | Cat | Dog;
/** List of pets */
export type GetPetsResponse = PetVariant[];
diff --git a/tests/integration/__snapshots__/discriminator-concrete-base.test.ts.snap b/tests/integration/__snapshots__/discriminator-concrete-base.test.ts.snap
new file mode 100644
index 0000000..a3c626d
--- /dev/null
+++ b/tests/integration/__snapshots__/discriminator-concrete-base.test.ts.snap
@@ -0,0 +1,199 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Discriminator concrete-base integration > discriminator-concrete-base-empty-31 (3.1 parity + empty-family fallback) > matches client snapshot > discriminator-concrete-base-empty-31-client 1`] = `
+"// Auto-generated by genoc from OpenAPI 3.1.0 spec. DO NOT EDIT.
+import { ApiError, UnspecifiedApiError, ErrorResponse, StreamResponse, RequesterFailError } from './contracts.js';
+import type { GetApiEntitiesFindQuery, GetApiEntitiesFindResponse, GetApiOrphansResponse } from './contracts.js';
+import { decorateWithErrors } from 'genoc/runtime';
+import type { Requester } from 'genoc/runtime';
+
+export { isDefinedError } from 'genoc/runtime';
+export type { Requester } from 'genoc/runtime';
+
+export function createClient(requester: Requester) {
+ return {
+ /**
+ * ok
+ */
+ getApiEntitiesFind: decorateWithErrors<(query?: GetApiEntitiesFindQuery) => Promise, never>(
+ async (query?: GetApiEntitiesFindQuery): Promise => {
+ try {
+ const result = await requester("GET", \`/api/Entities/Find\`, { query });
+ if (result instanceof ErrorResponse) {
+ throw new UnspecifiedApiError(result.status, result.data, result.message ?? \`Request failed with status \${result.status}\`);
+ }
+ if (result instanceof StreamResponse) {
+ throw new RequesterFailError(new Error("Unexpected stream response"));
+ }
+ return result;
+ } catch (error) {
+ if (error instanceof UnspecifiedApiError) throw error;
+ if (error instanceof ApiError) throw error;
+ throw new RequesterFailError(error);
+ }
+ },
+ [] as const,
+ ),
+ /**
+ * ok
+ */
+ getApiOrphans: decorateWithErrors<() => Promise, never>(
+ async (): Promise => {
+ try {
+ const result = await requester("GET", \`/api/Orphans\`, {});
+ if (result instanceof ErrorResponse) {
+ throw new UnspecifiedApiError(result.status, result.data, result.message ?? \`Request failed with status \${result.status}\`);
+ }
+ if (result instanceof StreamResponse) {
+ throw new RequesterFailError(new Error("Unexpected stream response"));
+ }
+ return result;
+ } catch (error) {
+ if (error instanceof UnspecifiedApiError) throw error;
+ if (error instanceof ApiError) throw error;
+ throw new RequesterFailError(error);
+ }
+ },
+ [] as const,
+ )
+ };
+}
+
+export type ApiClient = ReturnType;"
+`;
+
+exports[`Discriminator concrete-base integration > discriminator-concrete-base-empty-31 (3.1 parity + empty-family fallback) > matches contracts snapshot > discriminator-concrete-base-empty-31-contracts 1`] = `
+"// Auto-generated by genoc from OpenAPI 3.1.0 spec. DO NOT EDIT.
+
+import { ApiError, StreamResponse } from 'genoc/runtime';
+export {
+ ApiError,
+ UnspecifiedApiError,
+ DefaultApiError,
+ RequesterFailError,
+ StreamResponse,
+ ErrorResponse,
+} from 'genoc/runtime';
+
+export type Entity = {
+ id: string;
+ name: string;
+} & { $type: 'Entity' };
+
+export type EntityDetails = Omit & {
+ shortName: string;
+} & { $type: 'EntityDetails' };
+
+export type Orphan = ({ kind: 'unknown' } & Record);
+
+export type EntityVariant = Entity | EntityDetails;
+
+export type GetApiEntitiesFindQuery = {
+ take?: number;
+ skip?: number;
+};
+
+/** ok */
+export type GetApiEntitiesFindResponse = EntityVariant[];
+
+/** ok */
+export type GetApiOrphansResponse = Orphan;"
+`;
+
+exports[`Discriminator concrete-base integration > discriminator-concrete-base-entity (mapping lists the child only — bug 3) > matches client snapshot > discriminator-concrete-base-entity-client 1`] = `
+"// Auto-generated by genoc from OpenAPI 3.0.3 spec. DO NOT EDIT.
+import { ApiError, UnspecifiedApiError, ErrorResponse, StreamResponse, RequesterFailError } from './contracts.js';
+import type { GetApiEntitiesEntityIdResponse, GetApiEntitiesFindQuery, GetApiEntitiesFindResponse } from './contracts.js';
+import { decorateWithErrors } from 'genoc/runtime';
+import type { Requester } from 'genoc/runtime';
+
+export { isDefinedError } from 'genoc/runtime';
+export type { Requester } from 'genoc/runtime';
+
+export function createClient(requester: Requester) {
+ return {
+ /**
+ * ok
+ */
+ getApiEntitiesFind: decorateWithErrors<(query?: GetApiEntitiesFindQuery) => Promise, never>(
+ async (query?: GetApiEntitiesFindQuery): Promise => {
+ try {
+ const result = await requester("GET", \`/api/Entities/Find\`, { query });
+ if (result instanceof ErrorResponse) {
+ throw new UnspecifiedApiError(result.status, result.data, result.message ?? \`Request failed with status \${result.status}\`);
+ }
+ if (result instanceof StreamResponse) {
+ throw new RequesterFailError(new Error("Unexpected stream response"));
+ }
+ return result;
+ } catch (error) {
+ if (error instanceof UnspecifiedApiError) throw error;
+ if (error instanceof ApiError) throw error;
+ throw new RequesterFailError(error);
+ }
+ },
+ [] as const,
+ ),
+ /**
+ * ok
+ */
+ getApiEntitiesByEntityId: decorateWithErrors<(entityId: string) => Promise, never>(
+ async (entityId: string): Promise => {
+ try {
+ const result = await requester("GET", \`/api/Entities/\${encodeURIComponent(String(entityId))}\`, {});
+ if (result instanceof ErrorResponse) {
+ throw new UnspecifiedApiError(result.status, result.data, result.message ?? \`Request failed with status \${result.status}\`);
+ }
+ if (result instanceof StreamResponse) {
+ throw new RequesterFailError(new Error("Unexpected stream response"));
+ }
+ return result;
+ } catch (error) {
+ if (error instanceof UnspecifiedApiError) throw error;
+ if (error instanceof ApiError) throw error;
+ throw new RequesterFailError(error);
+ }
+ },
+ [] as const,
+ )
+ };
+}
+
+export type ApiClient = ReturnType;"
+`;
+
+exports[`Discriminator concrete-base integration > discriminator-concrete-base-entity (mapping lists the child only — bug 3) > matches contracts snapshot > discriminator-concrete-base-entity-contracts 1`] = `
+"// Auto-generated by genoc from OpenAPI 3.0.3 spec. DO NOT EDIT.
+
+import { ApiError, StreamResponse } from 'genoc/runtime';
+export {
+ ApiError,
+ UnspecifiedApiError,
+ DefaultApiError,
+ RequesterFailError,
+ StreamResponse,
+ ErrorResponse,
+} from 'genoc/runtime';
+
+export type Entity = {
+ id: string;
+ name: string;
+} & { $type: 'Entity' };
+
+export type EntityDetails = Omit & {
+ shortName: string;
+} & { $type: 'EntityDetails' };
+
+export type EntityVariant = Entity | EntityDetails;
+
+export type GetApiEntitiesFindQuery = {
+ take?: number;
+ skip?: number;
+};
+
+/** ok */
+export type GetApiEntitiesFindResponse = EntityVariant[];
+
+/** ok */
+export type GetApiEntitiesEntityIdResponse = EntityDetails;"
+`;
diff --git a/tests/integration/discriminator-concrete-base.test.ts b/tests/integration/discriminator-concrete-base.test.ts
new file mode 100644
index 0000000..ad49009
--- /dev/null
+++ b/tests/integration/discriminator-concrete-base.test.ts
@@ -0,0 +1,288 @@
+import { writeFileSync } from 'fs';
+import { mkdtemp } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+import { generateOutput } from '../../src/generator/client-generator.js';
+import { loadFromFile } from '../../src/parser/spec-reader.js';
+import type { GeneratorConfig } from '../../src/types/client.js';
+import type { OpenAPIDocument } from '../../src/types/openapi.js';
+import { analyzeFixture } from '../analyze-fixture.js';
+import { expectFilesCompile } from '../helpers/compile-check.js';
+import { linkGenoc } from '../helpers/link-genoc.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+interface GeneratedOutput {
+ doc: OpenAPIDocument;
+ contracts: string;
+ client: string;
+}
+
+async function generateFixture(relativePath: string): Promise {
+ const fixturePath = join(__dirname, '../fixtures', relativePath);
+ const doc = await loadFromFile(fixturePath);
+ const config: GeneratorConfig = {
+ input: fixturePath,
+ outputDir: '/tmp/discriminator-concrete-base-test',
+ };
+ const result = generateOutput(analyzeFixture(doc), config);
+ return { doc, contracts: result.contracts, client: result.client };
+}
+
+/**
+ * Extract one `export type NAME = ...;` definition block (multi-line safe:
+ * spans until the next top-level export or end of file).
+ */
+function typeBlock(contracts: string, name: string): string {
+ const start = contracts.indexOf(`export type ${name} = `);
+ if (start < 0) {
+ throw new Error(`type ${name} not found in contracts`);
+ }
+ const nextExport = contracts.indexOf('\nexport ', start + 1);
+ return contracts.slice(start, nextExport === -1 ? undefined : nextExport).trimEnd();
+}
+
+async function compileGenerated(output: GeneratedOutput): Promise {
+ const tmpDir = await mkdtemp(join(tmpdir(), 'discriminator-concrete-base-'));
+ linkGenoc(tmpDir);
+ writeFileSync(join(tmpDir, 'contracts.ts'), output.contracts, 'utf-8');
+ writeFileSync(join(tmpDir, 'client.ts'), output.client, 'utf-8');
+ expectFilesCompile([join(tmpDir, 'client.ts')]);
+}
+
+/** Compile an arbitrary assertion file alongside the generated contracts. */
+async function compileAssertionFile(
+ contracts: string,
+ fileName: string,
+ source: string
+): Promise {
+ const tmpDir = await mkdtemp(join(tmpdir(), 'discriminator-concrete-base-'));
+ linkGenoc(tmpDir);
+ writeFileSync(join(tmpDir, 'contracts.ts'), contracts, 'utf-8');
+ writeFileSync(join(tmpDir, fileName), source, 'utf-8');
+ expectFilesCompile([join(tmpDir, fileName)]);
+}
+
+/**
+ * Issue #93's four probe assertions for the Entity family: a list endpoint
+ * returning the base accepts plain base items (P1) and full child items (P2),
+ * narrowing on the discriminator reaches child props with the base as the
+ * else branch (P3), and the child endpoint stays exactly the child (P4).
+ */
+const ENTITY_PAYLOAD_ASSERTIONS = `import type {
+ GetApiEntitiesFindResponse,
+ GetApiEntitiesEntityIdResponse,
+} from './contracts.js';
+
+// P1: a plain base Entity is a valid item of the Find array.
+const base: GetApiEntitiesFindResponse = [{ $type: 'Entity', id: '1', name: 'a' }];
+
+// P2: a full EntityDetails item is still accepted in the list.
+const det: GetApiEntitiesFindResponse = [
+ { $type: 'EntityDetails', id: '1', name: 'a', shortName: 's' },
+];
+
+// P3: narrowing on the discriminator reaches shortName, and the else branch
+// is the base, which has no shortName.
+declare const list: GetApiEntitiesFindResponse;
+for (const e of list) {
+ if (e.$type === 'EntityDetails') {
+ const s: string = e.shortName;
+ void s;
+ } else {
+ // @ts-expect-error base has no shortName
+ void e.shortName;
+ }
+}
+
+// P4: the details endpoint is exactly EntityDetails.
+declare const one: GetApiEntitiesEntityIdResponse;
+const s2: string = one.shortName;
+const t: 'EntityDetails' = one.$type;
+void base;
+void det;
+void s2;
+void t;
+`;
+
+/** Bug 1 assertions for the OAS Pet/Cat/Dog example: every arm usable, union accepts each arm, narrowing works. */
+const OAS_PAYLOAD_ASSERTIONS = `import type { GetPetsResponse, Pet, Cat, Dog } from './contracts.js';
+
+const petOk: Pet = { petType: 'Pet', name: 'Generic' };
+const catOk: Cat = { petType: 'Cat', name: 'Tom', huntingSkill: 'lazy' };
+const dogOk: Dog = { petType: 'Dog', name: 'Rex', packSize: 3 };
+
+const listOk: GetPetsResponse = [
+ { petType: 'Pet', name: 'Generic' },
+ { petType: 'Cat', name: 'Tom', huntingSkill: 'lazy' },
+ { petType: 'Dog', name: 'Rex', packSize: 3 },
+];
+
+declare const list: GetPetsResponse;
+for (const p of list) {
+ if (p.petType === 'Cat') {
+ const h: string = p.huntingSkill;
+ void h;
+ } else {
+ // @ts-expect-error only Cat has huntingSkill
+ void p.huntingSkill;
+ }
+}
+
+void petOk;
+void catOk;
+void dogOk;
+void listOk;
+`;
+
+describe('Discriminator concrete-base integration', () => {
+ describe('discriminator-concrete-base-oas (OAS Pet example, no mapping — bug 1)', () => {
+ let output: GeneratedOutput;
+
+ beforeAll(async () => {
+ output = await generateFixture('discriminator-concrete-base-oas.json');
+ });
+
+ it('declares PetVariant covering the base and the implicit allOf children', () => {
+ expect(output.contracts).toContain('export type PetVariant = Pet | Cat | Dog;');
+ });
+
+ it('Pet carries its own literal; children extend it via Omit', () => {
+ expect(typeBlock(output.contracts, 'Pet')).toBe(
+ "export type Pet = {\n name: string;\n} & { petType: 'Pet' };"
+ );
+ expect(typeBlock(output.contracts, 'Cat')).toBe(
+ "export type Cat = Omit & {\n huntingSkill: string;\n} & { petType: 'Cat' };"
+ );
+ expect(typeBlock(output.contracts, 'Dog')).toBe(
+ "export type Dog = Omit & {\n packSize: number;\n} & { petType: 'Dog' };"
+ );
+ });
+
+ it('the list response is PetVariant[]', () => {
+ expect(output.contracts).toContain('export type GetPetsResponse = PetVariant[];');
+ });
+
+ it('generated contracts and client compile with tsc --strict --noEmit', async () => {
+ await compileGenerated(output);
+ });
+
+ it('payload and narrowing assertions compile against the contracts', async () => {
+ await compileAssertionFile(output.contracts, 'oas-assertions.ts', OAS_PAYLOAD_ASSERTIONS);
+ });
+ });
+
+ describe('discriminator-concrete-base-entity (mapping lists the child only — bug 3)', () => {
+ let output: GeneratedOutput;
+
+ beforeAll(async () => {
+ output = await generateFixture('discriminator-concrete-base-entity.json');
+ });
+
+ it('EntityVariant covers the concrete base and its mapped child', () => {
+ expect(typeBlock(output.contracts, 'EntityVariant')).toBe(
+ 'export type EntityVariant = Entity | EntityDetails;'
+ );
+ });
+
+ it('Entity carries its own literal; EntityDetails replaces it via Omit', () => {
+ expect(typeBlock(output.contracts, 'Entity')).toBe(
+ "export type Entity = {\n id: string;\n name: string;\n} & { $type: 'Entity' };"
+ );
+ expect(typeBlock(output.contracts, 'EntityDetails')).toBe(
+ "export type EntityDetails = Omit & {\n shortName: string;\n} & { $type: 'EntityDetails' };"
+ );
+ });
+
+ it('Find is the union array; the details endpoint stays exactly EntityDetails', () => {
+ expect(output.contracts).toContain(
+ 'export type GetApiEntitiesFindResponse = EntityVariant[];'
+ );
+ expect(output.contracts).toContain(
+ 'export type GetApiEntitiesEntityIdResponse = EntityDetails;'
+ );
+ });
+
+ it('matches contracts snapshot', () => {
+ expect(output.contracts).toMatchSnapshot('discriminator-concrete-base-entity-contracts');
+ });
+
+ it('matches client snapshot', () => {
+ expect(output.client).toMatchSnapshot('discriminator-concrete-base-entity-client');
+ });
+
+ it('generated contracts and client compile with tsc --strict --noEmit', async () => {
+ await compileGenerated(output);
+ });
+
+ it('P1–P4 payload and narrowing assertions compile against the contracts', async () => {
+ await compileAssertionFile(
+ output.contracts,
+ 'entity-assertions.ts',
+ ENTITY_PAYLOAD_ASSERTIONS
+ );
+ });
+ });
+
+ describe('discriminator-concrete-base-selfmap (base listed in its own mapping — bug 2)', () => {
+ let output: GeneratedOutput;
+ let unselfMapped: GeneratedOutput;
+
+ beforeAll(async () => {
+ output = await generateFixture('discriminator-concrete-base-selfmap.json');
+ unselfMapped = await generateFixture('discriminator-concrete-base-entity.json');
+ });
+
+ it('produces contracts identical to the non-self-mapped family', () => {
+ expect(output.contracts).toBe(unselfMapped.contracts);
+ });
+
+ it('generated contracts and client compile with tsc --strict --noEmit', async () => {
+ await compileGenerated(output);
+ });
+
+ it('P1–P4 payload and narrowing assertions compile against the contracts', async () => {
+ await compileAssertionFile(
+ output.contracts,
+ 'selfmap-assertions.ts',
+ ENTITY_PAYLOAD_ASSERTIONS
+ );
+ });
+ });
+
+ describe('discriminator-concrete-base-empty-31 (3.1 parity + empty-family fallback)', () => {
+ let output: GeneratedOutput;
+
+ beforeAll(async () => {
+ output = await generateFixture('discriminator-concrete-base-empty-31.json');
+ });
+
+ it('the 3.1 self-mapped family joins its base the same way as 3.0', () => {
+ expect(typeBlock(output.contracts, 'EntityVariant')).toBe(
+ 'export type EntityVariant = Entity | EntityDetails;'
+ );
+ expect(output.contracts).toContain(
+ 'export type GetApiEntitiesFindResponse = EntityVariant[];'
+ );
+ });
+
+ it('an empty family falls back to bare base refs and emits no union type', () => {
+ expect(output.contracts).not.toContain('OrphanVariant');
+ expect(output.contracts).toContain('export type GetApiOrphansResponse = Orphan;');
+ });
+
+ it('matches contracts snapshot', () => {
+ expect(output.contracts).toMatchSnapshot('discriminator-concrete-base-empty-31-contracts');
+ });
+
+ it('matches client snapshot', () => {
+ expect(output.client).toMatchSnapshot('discriminator-concrete-base-empty-31-client');
+ });
+
+ it('generated contracts and client compile with tsc --strict --noEmit', async () => {
+ await compileGenerated(output);
+ });
+ });
+});
diff --git a/tests/integration/discriminator-cross-variant.test.ts b/tests/integration/discriminator-cross-variant.test.ts
index 3f992df..ffb4f3c 100644
--- a/tests/integration/discriminator-cross-variant.test.ts
+++ b/tests/integration/discriminator-cross-variant.test.ts
@@ -76,13 +76,15 @@ describe('Discriminator cross-variant integration', () => {
it('T1: UpdateThing keeps the base spine and a single Full literal', () => {
const update = typeBlock(output.contracts, 'UpdateThing');
- expect(update).toMatch(/^export type UpdateThing = BaseThing &/);
+ // #93: the concrete base joins its own union, so the child spine strips
+ // the base's discriminant (`Omit`) instead of inheriting it verbatim.
+ expect(update).toMatch(/^export type UpdateThing = Omit &/);
expect(update).toMatch(/\{ ["']?\$type["']?: 'Full' \};$/);
});
- it('T1: PartialThing shape is unchanged (regression pin)', () => {
+ it('T1: PartialThing keeps the base spine with the discriminant stripped (#93)', () => {
expect(typeBlock(output.contracts, 'PartialThing')).toBe(
- "export type PartialThing = BaseThing & Record & { $type: 'Partial' };"
+ "export type PartialThing = Omit & Record & { $type: 'Partial' };"
);
});
@@ -201,7 +203,8 @@ describe('Discriminator cross-variant integration', () => {
// the renamed variants.
const duplicateDeclarations = output.contracts.match(/^export type ThingVariant = /gm);
expect(duplicateDeclarations).toHaveLength(1);
- expect(output.contracts).toMatch(/export type \w+ = UserDto \| UserDtoModel;/);
+ // #93: the concrete base joins its own union as the first member.
+ expect(output.contracts).toMatch(/export type \w+ = Thing \| UserDto \| UserDtoModel;/);
});
it('generated contracts and client compile with tsc --strict --noEmit', async () => {
diff --git a/tests/type-assertions/cross-variant-assignability.ts b/tests/type-assertions/cross-variant-assignability.ts
index f429032..1082dca 100644
--- a/tests/type-assertions/cross-variant-assignability.ts
+++ b/tests/type-assertions/cross-variant-assignability.ts
@@ -4,16 +4,18 @@
// Run: npx tsc --strict --noEmit --module nodenext --moduleResolution nodenext \
// --ignoreConfig tests/type-assertions/cross-variant-assignability.ts
//
-// Asserts the discriminator cross-variant semantics: every variant type is
-// usable (not `never`), accepts a fully-populated object literal carrying the
-// parent's props plus the variant's OWN discriminant literal, and rejects
-// sibling / wrong-discriminant literals. This file compiles standalone —
-// `genoc/runtime` is the only external module a fresh clone needs to resolve.
+// Asserts the discriminator cross-variant semantics: every variant type —
+// including a concrete base that joins its own union (#93) — is usable
+// (not `never`), accepts a fully-populated object literal carrying its OWN
+// discriminant literal, and rejects sibling / wrong-discriminant literals.
+// This file compiles standalone — `genoc/runtime` is the only external module
+// a fresh clone needs to resolve.
//
// Like tests/type-assertions/is-defined-error-types.ts, the shapes below are
// inline replicas of generator output (not imports of generated files), so the
// assertions survive without a generation step. They were transcribed verbatim
-// from real generated contracts on fix/discriminator-cross-variant @ e278932:
+// from real generated contracts on the #93 concrete-base fix (base joins its
+// union, children strip the discriminant via Omit):
// node dist/cli/index.js tests/fixtures/dotnet-polymorphism-cross-variant.json --output-dir .tmp-qa/cross-variant
// node dist/cli/index.js tests/fixtures/discriminator-spine-topologies.json --output-dir .tmp-qa/spine-topologies
// If the generator changes shape, regenerate and re-transcribe before trusting a green run.
@@ -23,6 +25,7 @@ type Accepts = U extends T ? true : false;
// --- Replica of generated format-branded strings -----------------------------
type UuidString = string & { readonly __format?: 'uuid' };
+type DateTimeString = string & { readonly __format?: 'date-time' };
// Shared values for branded props (keeps the sweep about variant usability,
// not about string-literal assignability to branded strings).
@@ -30,25 +33,41 @@ declare const anId: UuidString;
// ===========================================================================
// Fixture: dotnet-polymorphism-cross-variant.json
-// Family BaseThing (prop `$type`): Partial / Full / Create
-// Family BaseArtifact (prop `$type`): Bulletin
+// Family BaseThing (prop `$type`): BaseThing / Partial / Full / Create
+// Family BaseArtifact (prop `$type`): BaseArtifact / Bulletin
// ===========================================================================
-type BaseThing = { $type: string; id: UuidString };
-type PartialThing = BaseThing & Record & { $type: 'Partial' };
-type UpdateThing = BaseThing & {
+type BaseThing = {
+ id: UuidString;
+} & { $type: 'BaseThing' };
+type PartialThing = Omit & Record & { $type: 'Partial' };
+type UpdateThing = Omit & {
name: string;
description?: string;
+ detectionDateTime?: DateTimeString;
bulletin?: BulletinArtifact;
+ affectedReleaseIds?: number[];
} & { $type: 'Full' };
type CreateThing = Omit & Record & { $type: 'Create' };
-type BaseThingVariant = PartialThing | UpdateThing | CreateThing;
+type BaseThingVariant = BaseThing | PartialThing | UpdateThing | CreateThing;
-type BaseArtifact = { $type: string; artifactId: string };
-type BulletinArtifact = BaseArtifact & {
+type BaseArtifact = {
+ artifactId: string;
+} & { $type: 'BaseArtifact' };
+type BulletinArtifact = Omit & {
url: string;
} & { $type: 'Bulletin' };
-type BaseArtifactVariant = BulletinArtifact;
+type BaseArtifactVariant = BaseArtifact | BulletinArtifact;
+
+// --- BaseThing (concrete base joins its own union, #93) ----------------------
+declare const baseThingIsNever: IsNever;
+const _baseThingNotNever: false = baseThingIsNever;
+const baseThingOk: BaseThing = { $type: 'BaseThing', id: anId };
+declare const baseThingRejectsPartial: Accepts<
+ BaseThing,
+ { $type: 'Partial'; id: UuidString; extra: string }
+>;
+const _baseThingRejectsPartial: false = baseThingRejectsPartial;
// --- PartialThing ------------------------------------------------------------
declare const partialIsNever: IsNever;
@@ -89,13 +108,14 @@ const _createRejectsPartial: false = createRejectsPartial;
// --- BaseThingVariant union (always emitted, collision-safe name) ------------
declare const baseThingVariantIsNever: IsNever;
const _baseThingVariantNotNever: false = baseThingVariantIsNever;
+const variantAsBase: BaseThingVariant = { $type: 'BaseThing', id: anId };
const variantAsPartial: BaseThingVariant = { $type: 'Partial', id: anId };
const variantAsUpdate: BaseThingVariant = { $type: 'Full', id: anId, name: 'u' };
const variantAsCreate: BaseThingVariant = { $type: 'Create', id: anId, name: 'c' };
declare const variantRejectsEmpty: Accepts>;
const _variantRejectsEmpty: false = variantRejectsEmpty;
-// --- BulletinArtifact (single-member family still gets its own variant) ------
+// --- BulletinArtifact (mapped variant still Omit-strips the base literal) ----
declare const bulletinIsNever: IsNever;
const _bulletinNotNever: false = bulletinIsNever;
const bulletinOk: BulletinArtifact = { $type: 'Bulletin', artifactId: 'a1', url: 'https://x' };
@@ -108,6 +128,7 @@ const _bulletinRejectsWrongLiteral: false = bulletinRejectsWrongLiteral;
// --- BaseArtifactVariant union -----------------------------------------------
declare const baseArtifactVariantIsNever: IsNever;
const _baseArtifactVariantNotNever: false = baseArtifactVariantIsNever;
+const artifactVariantAsBase: BaseArtifactVariant = { $type: 'BaseArtifact', artifactId: 'a1' };
const artifactVariantOk: BaseArtifactVariant = {
$type: 'Bulletin',
artifactId: 'a1',
@@ -120,10 +141,10 @@ const artifactVariantOk: BaseArtifactVariant = {
// Uni0(uni) / Self0(self)
// ===========================================================================
-type Chain0 = { chain: string };
-type ChainMid = Chain0 & { level: number } & { chain: 'mid' };
+type Chain0 = {} & { chain: 'Chain0' };
+type ChainMid = Omit & { level: number } & { chain: 'mid' };
type ChainLeaf = Omit & { leafNote: string } & { chain: 'leaf' };
-type Chain0Variant = ChainMid | ChainLeaf;
+type Chain0Variant = Chain0 | ChainMid | ChainLeaf;
// --- ChainMid / ChainLeaf (grandchild chain: Omit through the middle) --------
declare const chainMidIsNever: IsNever;
@@ -141,19 +162,20 @@ const chainLeafOk: ChainLeaf = { chain: 'leaf', level: 2, leafNote: 'l' };
declare const chainLeafRejectsMid: Accepts;
const _chainLeafRejectsMid: false = chainLeafRejectsMid;
+const chainVariantAsBase: Chain0Variant = { chain: 'Chain0' };
const chainVariantOk: Chain0Variant = { chain: 'leaf', level: 2, leafNote: 'l' };
declare const chainVariantIsNever: IsNever;
const _chainVariantNotNever: false = chainVariantIsNever;
declare const chainVariantRejectsNoDiscriminant: Accepts;
const _chainVariantRejectsNoDiscriminant: false = chainVariantRejectsNoDiscriminant;
-type Loop0 = { loop: string };
-type LoopAlpha = Loop0 & { alphaNote: string } & { loop: 'alpha' };
-type LoopBeta = Loop0 & {
+type Loop0 = {} & { loop: 'Loop0' };
+type LoopAlpha = Omit & { alphaNote: string } & { loop: 'alpha' };
+type LoopBeta = Omit & {
betaNote: string;
partner?: LoopAlpha;
} & { loop: 'beta' };
-type Loop0Variant = LoopAlpha | LoopBeta;
+type Loop0Variant = Loop0 | LoopAlpha | LoopBeta;
// --- LoopAlpha / LoopBeta (property-ref back-edge stays a bare name) ---------
declare const loopAlphaIsNever: IsNever;
@@ -170,14 +192,18 @@ const loopBetaOkMinimal: LoopBeta = { loop: 'beta', betaNote: 'b' };
declare const loopBetaRejectsAlpha: Accepts;
const _loopBetaRejectsAlpha: false = loopBetaRejectsAlpha;
+const loopVariantAsBase: Loop0Variant = { loop: 'Loop0' };
declare const loopVariantIsNever: IsNever;
const _loopVariantNotNever: false = loopVariantIsNever;
const loopVariantOk: Loop0Variant = { loop: 'alpha', alphaNote: 'a' };
-type Multi0 = { mult: string };
-type MultiLeft = Multi0 & { leftNote: string } & { mult: 'left' };
-type MultiRight = Multi0 & Omit & { rightNote: string } & { mult: 'right' };
-type Multi0Variant = MultiLeft | MultiRight;
+type Multi0 = {} & { mult: 'Multi0' };
+type MultiLeft = Omit & { leftNote: string } & { mult: 'left' };
+type MultiRight = Omit &
+ Omit & { rightNote: string } & {
+ mult: 'right';
+ };
+type Multi0Variant = Multi0 | MultiLeft | MultiRight;
// --- MultiLeft / MultiRight (multi-parent: base + same-family sibling) -------
declare const multiLeftIsNever: IsNever;
@@ -195,13 +221,14 @@ const multiRightOk: MultiRight = { mult: 'right', leftNote: 'l', rightNote: 'r'
declare const multiRightRejectsLeft: Accepts;
const _multiRightRejectsLeft: false = multiRightRejectsLeft;
+const multiVariantAsBase: Multi0Variant = { mult: 'Multi0' };
declare const multiVariantIsNever: IsNever;
const _multiVariantNotNever: false = multiVariantIsNever;
const multiVariantOk: Multi0Variant = { mult: 'right', leftNote: 'l', rightNote: 'r' };
-type Const0 = { mode: string };
-type ConstFast = Const0 & { speed: number } & { mode: 'fast' };
-type Const0Variant = ConstFast;
+type Const0 = {} & { mode: 'Const0' };
+type ConstFast = Omit & { speed: number } & { mode: 'fast' };
+type Const0Variant = Const0 | ConstFast;
// --- ConstFast (own-enum conflict: mapping key wins, own 'slow' dropped) -----
declare const constFastIsNever: IsNever;
@@ -210,16 +237,17 @@ const constFastOk: ConstFast = { mode: 'fast', speed: 42 };
declare const constFastRejectsOwnEnum: Accepts;
const _constFastRejectsOwnEnum: false = constFastRejectsOwnEnum;
+const constVariantAsBase: Const0Variant = { mode: 'Const0' };
declare const constVariantIsNever: IsNever;
const _constVariantNotNever: false = constVariantIsNever;
const constVariantOk: Const0Variant = { mode: 'fast', speed: 42 };
-type Uni0 = { uni: string };
+type Uni0 = {} & { uni: 'Uni0' };
type UniA1 = { aOne: string };
type UniA2 = { aTwo: string };
type UniMix = (UniA1 | UniA2) & { uni: 'mix' };
-type UniPick = Uni0 & (UniA1 | UniA2) & { pickNote: string } & { uni: 'pick' };
-type Uni0Variant = UniMix | UniPick;
+type UniPick = Omit & (UniA1 | UniA2) & { pickNote: string } & { uni: 'pick' };
+type Uni0Variant = Uni0 | UniMix | UniPick;
// --- UniMix / UniPick (oneOf-union spine target: literal appends to union) ---
declare const uniMixIsNever: IsNever;
@@ -236,18 +264,20 @@ const uniPickOkA2: UniPick = { uni: 'pick', aTwo: '2', pickNote: 'p' };
declare const uniPickRejectsMix: Accepts;
const _uniPickRejectsMix: false = uniPickRejectsMix;
+const uniVariantAsBase: Uni0Variant = { uni: 'Uni0' };
declare const uniVariantIsNever: IsNever;
const _uniVariantNotNever: false = uniVariantIsNever;
const uniVariantOk: Uni0Variant = { uni: 'mix', aOne: '1' };
-type Self0 = { self: string };
-type Self0Variant = SelfKid;
-type SelfKid = Self0 & {
+type Self0 = {} & { self: 'Self0' };
+type SelfKid = Omit & {
parent: Self0Variant;
kidNote?: string;
} & { self: 'kid' };
+type Self0Variant = Self0 | SelfKid;
// --- SelfKid (base ref in a property widens: parent: Self0Variant) -----------
+const selfVariantAsBase: Self0Variant = { self: 'Self0' };
declare const selfVariantValue: Self0Variant;
declare const selfKidIsNever: IsNever;
const _selfKidNotNever: false = selfKidIsNever;
diff --git a/tests/unit/discriminator-registry.test.ts b/tests/unit/discriminator-registry.test.ts
index a127e97..7cfb9b2 100644
--- a/tests/unit/discriminator-registry.test.ts
+++ b/tests/unit/discriminator-registry.test.ts
@@ -334,6 +334,50 @@ describe('discriminator registry', () => {
});
});
+ describe('self-mapped base literal conflict warning (#93)', () => {
+ it('keeps the self-mapping key and warns when the base declares a conflicting own literal', () => {
+ const warnSink = vi.fn();
+ const registry = buildRegistry(
+ {
+ Base: {
+ ...objectSchema({ kind: { const: 'wrong-literal' } }),
+ discriminator: {
+ propertyName: 'kind',
+ mapping: { Base: '#/components/schemas/Base' },
+ },
+ },
+ },
+ warnSink
+ );
+
+ const base = registry.families.get('Base')?.entriesByRef.get('#/components/schemas/Base');
+ expect(base?.literalValue).toBe('Base');
+ expect(warnSink).toHaveBeenCalledTimes(1);
+ expect(warnSink.mock.calls[0][0]).toContain('"Base"');
+ expect(warnSink.mock.calls[0][0]).toContain('"wrong-literal"');
+ });
+
+ it('does not warn when the self-mapping key matches the own literal', () => {
+ const warnSink = vi.fn();
+ const registry = buildRegistry(
+ {
+ Base: {
+ ...objectSchema({ kind: { const: 'Base' } }),
+ discriminator: {
+ propertyName: 'kind',
+ mapping: { Base: '#/components/schemas/Base' },
+ },
+ },
+ },
+ warnSink
+ );
+
+ const base = registry.families.get('Base')?.entriesByRef.get('#/components/schemas/Base');
+ expect(base?.literalValue).toBe('Base');
+ expect(warnSink).not.toHaveBeenCalled();
+ });
+ });
+
describe('variant union naming (D6)', () => {
it('renames the {Base}Variant union when a user schema already owns that name', () => {
const registry = buildRegistry({
@@ -350,7 +394,8 @@ describe('discriminator registry', () => {
const family = registry.families.get('Thing');
expect(family?.variantUnionName).toBe('ThingVariantModel');
- expect(family?.variantUnionMembers).toEqual(['UpdateThing']);
+ // #93: the concrete base joins its own union as the first member.
+ expect(family?.variantUnionMembers).toEqual(['Thing', 'UpdateThing']);
});
it('keeps the plain {Base}Variant name when no collision exists', () => {
diff --git a/tests/validation/__snapshots__/mega-spec-3.0.test.ts.snap b/tests/validation/__snapshots__/mega-spec-3.0.test.ts.snap
index 782e301..86f8f8b 100644
--- a/tests/validation/__snapshots__/mega-spec-3.0.test.ts.snap
+++ b/tests/validation/__snapshots__/mega-spec-3.0.test.ts.snap
@@ -74,9 +74,8 @@ export type AnyOfSchema = string | number;
/** Discriminator schema */
export type DiscriminatorSchema = {
- "type": string;
value?: string;
-};
+} & { "type": 'DiscriminatorSchema' };
/** Enum schema */
export type EnumSchema = 'option1' | 'option2' | 'option3';
@@ -206,7 +205,7 @@ export type RefWithSibling = (RefTarget & {
/** Raw file content */
export type File = string;
-export type DiscriminatorSchemaVariant = StringType | NumberType;
+export type DiscriminatorSchemaVariant = DiscriminatorSchema | StringType | NumberType;
export type ApiKeyQueryAuth = { type: 'apiKey'; name: 'api_key'; in: 'query' };
@@ -2840,9 +2839,8 @@ export type AnyOfSchema = string | number;
/** Discriminator schema */
export type DiscriminatorSchema = {
- "type": string;
value?: string;
-};
+} & { "type": 'DiscriminatorSchema' };
/** Enum schema */
export type EnumSchema = 'option1' | 'option2' | 'option3';
@@ -2972,7 +2970,7 @@ export type RefWithSibling = (RefTarget & {
/** Raw file content */
export type File = string;
-export type DiscriminatorSchemaVariant = StringType | NumberType;
+export type DiscriminatorSchemaVariant = DiscriminatorSchema | StringType | NumberType;
export type ApiKeyQueryAuth = { type: 'apiKey'; name: 'api_key'; in: 'query' };
diff --git a/tests/validation/__snapshots__/mega-spec-3.1.test.ts.snap b/tests/validation/__snapshots__/mega-spec-3.1.test.ts.snap
index b36f45a..b362050 100644
--- a/tests/validation/__snapshots__/mega-spec-3.1.test.ts.snap
+++ b/tests/validation/__snapshots__/mega-spec-3.1.test.ts.snap
@@ -57,9 +57,8 @@ export type AnyOfSchema = string | number;
/** Discriminator schema */
export type DiscriminatorSchema = {
- "type": string;
value?: string;
-};
+} & { "type": 'DiscriminatorSchema' };
/** Enum schema */
export type EnumSchema = 'option1' | 'option2' | 'option3';
@@ -157,7 +156,7 @@ export type CircularA = {
reference?: CircularB;
};
-export type DiscriminatorSchemaVariant = StringType | NumberType;
+export type DiscriminatorSchemaVariant = DiscriminatorSchema | StringType | NumberType;
export type ApiKeyQueryAuth = { type: 'apiKey'; name: 'api_key'; in: 'query' };
@@ -2367,9 +2366,8 @@ export type AnyOfSchema = string | number;
/** Discriminator schema */
export type DiscriminatorSchema = {
- "type": string;
value?: string;
-};
+} & { "type": 'DiscriminatorSchema' };
/** Enum schema */
export type EnumSchema = 'option1' | 'option2' | 'option3';
@@ -2467,7 +2465,7 @@ export type CircularA = {
reference?: CircularB;
};
-export type DiscriminatorSchemaVariant = StringType | NumberType;
+export type DiscriminatorSchemaVariant = DiscriminatorSchema | StringType | NumberType;
export type ApiKeyQueryAuth = { type: 'apiKey'; name: 'api_key'; in: 'query' };
diff --git a/tests/validation/__snapshots__/openapi-3.0-data-types-keywords.test.ts.snap b/tests/validation/__snapshots__/openapi-3.0-data-types-keywords.test.ts.snap
index 7d2d336..80db946 100644
--- a/tests/validation/__snapshots__/openapi-3.0-data-types-keywords.test.ts.snap
+++ b/tests/validation/__snapshots__/openapi-3.0-data-types-keywords.test.ts.snap
@@ -134,15 +134,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -150,7 +148,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;
@@ -192,15 +190,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -208,7 +204,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;
@@ -226,15 +222,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -242,7 +236,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;
diff --git a/tests/validation/__snapshots__/openapi-3.1-data-types-keywords.test.ts.snap b/tests/validation/__snapshots__/openapi-3.1-data-types-keywords.test.ts.snap
index 332c620..500c314 100644
--- a/tests/validation/__snapshots__/openapi-3.1-data-types-keywords.test.ts.snap
+++ b/tests/validation/__snapshots__/openapi-3.1-data-types-keywords.test.ts.snap
@@ -108,15 +108,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -124,7 +122,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;
@@ -166,15 +164,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -182,7 +178,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;
@@ -200,15 +196,13 @@ export {
ErrorResponse,
} from 'genoc/runtime';
-export type Pet = {
- "petType": string;
-};
+export type Pet = {} & { petType: 'Pet' };
-export type Cat = Pet & {
+export type Cat = Omit & {
meow?: boolean;
} & { petType: 'cat' };
-export type Dog = Pet & {
+export type Dog = Omit & {
bark?: boolean;
} & { petType: 'dog' };
@@ -216,7 +210,7 @@ export type PetVariant = Cat | Dog;
export type MixedUnion = string | number;
-export type PetVariantModel = Cat | Dog;
+export type PetVariantModel = Pet | Cat | Dog;
export type PetVariantVariant = Cat | Dog;"
`;