Skip to content

fix(openapi): keep a discriminated union variant an object when it is a bare allOf reference - #17577

Open
wiebren wants to merge 2 commits into
fern-api:mainfrom
wiebren:fix/discriminated-union-variant-bare-allof-ref
Open

fix(openapi): keep a discriminated union variant an object when it is a bare allOf reference#17577
wiebren wants to merge 2 commits into
fern-api:mainfrom
wiebren:fix/discriminated-union-variant-bare-allof-ref

Conversation

@wiebren

@wiebren wiebren commented Aug 28, 2026

Copy link
Copy Markdown

Description

Linear ticket: n/a — found while running the same OpenAPI document through all six SDK
generators and comparing what each one did with a discriminated error union.

A variant of a discriminated union that is spelled as an allOf containing nothing but the
reference to its base does not deserialize. The generated client expects the variant's
payload nested under a value key that the wire format does not have.

ApiError:
  type: object
  required: [type, message]
  properties:
    type: { type: string }
    message: { type: string }
  discriminator:
    propertyName: type
    mapping:
      ProcessError: '#/components/schemas/ProcessError'

ProcessError:
  allOf:
    - $ref: '#/components/schemas/ApiError'   # and nothing else

Given {"type": "ProcessError", "message": "The process failed"}, python raises
ValidationError: 1 validation error for tagged-union[...], and csharp raises
KeyNotFoundException: The given key was not present in the dictionary.

The two spellings disagree

The same subtype written with a trailing empty object has always worked:

ProcessError:
  allOf:
    - $ref: '#/components/schemas/ApiError'
    - type: object                            # <- the only difference

These are equivalent in OpenAPI, so the divergence is the bug. convertSchemas.ts
short-circuits an allOf that reduces to a single element and converts that element
directly. The bare form reduces to one element and is short-circuited into an alias of
the base; the trailing-type: object form has two elements, never reaches the
short-circuit, and converts to an object.

The alias is what breaks it. The fern definition ends up declaring ProcessError: ApiError
— the variant refers to the union it belongs to — and the IR generator only emits a variant
as samePropertiesAsObject when the referenced type is an object. A non-object variant
degrades to singleProperty, which is the wrapped shape that puts the payload under value:

variant shape in the IR
allOf: [$ref, {type: object}] samePropertiesAsObject
allOf: [$ref] singleProperty

This is one importer bug, not six generator bugs

Every generator reads the same broken IR, so all six emit the wrapped shape; they differ only
in how loudly they fail.

Each client was generated twice from the same document — once with the released cli 5.106.1,
once with a locally built cli carrying this fix — and handed
{"type":"BareRef","message":"The process failed"}. Every row below was executed:

generator before after
python ValidationError: 1 validation error for tagged-union[...] parses, message present
csharp KeyNotFoundException: The given key was not present in the dictionary. parses, IsBareRef=True
php Exception: JSON data is missing property 'BareRef' parses, message present
java no exception, but BaseError{value: null}; getBareRef() is null so the accessor NPEs BaseError{value: {"message": "..."}}, getBareRef().isPresent()
go no error, but GetBareRef() is nil and Accept(visitor) returns type *api.BaseError does not define a non-empty union type variant present, visitor dispatches, returns nil
typescript does not compile: TS2322 ... Property 'value' is missing in type ... but required in type 'BareRef' compiles

go and typescript are worth calling out because neither validates the response body at
runtime — go leaves the zero value, typescript does no runtime checking at all — so this can
look like it only affects three languages. It does not: go's visitor dispatch still fails, and
typescript rejects the actual wire payload at compile time.

In every language BareRef is also emitted as an alias of the union (type BareRef = BaseError),
so the value a caller would have to supply is the union itself.

Worth noting the v3 importer (--from-openapi) already produces samePropertiesAsObject
for the bare form. This change removes a divergence between the two importers rather than
introducing a new behaviour.

Changes Made

  • convertSchemas.ts: skip the single-element allOf short-circuit when the schema being
    converted is named in the discriminator mapping of the schema it references. Both
    short-circuits (before and after the non-object elements are filtered out) are guarded.
  • The guard is deliberately narrow. It fires only when the referenced schema declares a
    discriminator whose mapping names this schema, so the common non-variant use of a
    single-reference allOf{allOf: [$ref X], nullable: true}, used to attach nullable
    to a reference — still short-circuits to a reference to X. An earlier, broader version
    of this guard (any reference to a schema with a discriminator) changed the vellum
    fixture's WorkflowResultEvent.output from nullable<WorkflowResultEventOutputData> to a
    new inline type; the mapping check is what keeps that fixture byte-identical.
  • Updated README.md generator (if applicable) — n/a

Testing

  • Unit tests added/updated
  • Manual testing completed

New fixture discriminated-union-variant-bare-allof-ref covers all four shapes in one
document: a variant that adds properties, the same variant with a trailing empty object, the
bare-$ref variant, and a non-variant {allOf: [$ref], nullable: true} that must keep
short-circuiting. It is picked up by both openapi.test.ts and openapi-ir.test.ts.

The snapshot pins both halves:

BareRef:
  properties: {}                      # was `BareRef: BaseError`
Holder:
  properties:
    nullableError: nullable<BaseError>  # unchanged

Verified the new test fails without the fix by stashing only convertSchemas.ts: 2 failed,
317 passed. With the fix, 763 tests pass across @fern-api/openapi-ir-parser (96),
@fern-api/openapi-ir-to-fern-tests (319), @fern-api/v3-importer-tests (336) and
@fern-api/openapi-to-ir (12), with no snapshot churn anywhere else. (Two fixtures log
Test Failed: Error processing fixture ... inside passing v3 tests; confirmed identical
without this change.)

End to end, all six generated clients were built and run against the payload, before and
after, as tabulated above — python under pydantic, csharp on .NET 9, java on JDK 21 with
Jackson, go under go test on 1.23, php 8.4 under composer, and typescript through tsc --strict. All six fail before and pass after.

The decisive structural check is that the bare form and the trailing-type: object form now
produce byte-identical IR — both the type declaration and the union variant, modulo the
name — so the fixed output is exactly the shape that already worked in every language rather
than a new one.

Notes

  • No changelog entry added, following the precedent of the unreleased directory being
    empty. Happy to add one if you would like it.
  • The generators are untouched; the wrapped singleProperty path they implement is still
    correct for variants that genuinely are non-object types.

Devin Review

Generated with Claude Code

…llOf reference

A variant of a discriminated union that is spelled as an allOf with nothing but the
reference to its base:

    ProcessError:
      allOf:
        - $ref: '#/components/schemas/ApiError'

was short-circuited to an alias of the base, so the fern definition declared
`ProcessError: ApiError` - the variant referred to the union it belongs to. The IR
generator only emits a variant as `samePropertiesAsObject` when the referenced type is an
object, so the variant degraded to `singleProperty` and every generator then expected the
variant's payload under a `value` key that the wire format does not have.

Spelling the same subtype with a trailing empty object:

    ProcessError:
      allOf:
        - $ref: '#/components/schemas/ApiError'
        - type: object

never reached the short-circuit and converted to an object all along. The two spellings
are equivalent in OpenAPI, so this brings them into agreement rather than inventing a
behaviour: the fixed output for the bare form is byte for byte what the trailing-empty-
object form already produced, both as a type declaration and as a union variant.

The short-circuit is kept for every other single-reference allOf. The guard fires only
when the referenced schema declares a discriminator whose mapping names the schema being
converted, so `{allOf: [$ref X], nullable: true}` - the common way to attach `nullable` to
a reference - still collapses to a reference to X.

The v3 importer already produced `samePropertiesAsObject` for the bare form, so this also
removes a divergence between the two importers.

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review Summary

Narrow, well-justified fix to keep bare allOf: [$ref Base] discriminator variants as objects. The main concern is the identity-based comparison in isVariantOfDiscriminatedBase, which is fragile in a couple of ways: it relies on resolveSchemaReference returning the same object instance, and it only compares against schema (the whole converted schema object), which may not hold for inline/nested cases. Also worth confirming the mapping-key vs. $ref-resolution path and the perf of resolving every mapping entry.

  • 🟡 1 warning(s)
  • 🔵 2 suggestion(s)

To request another review, comment /ai-review on this pull request.

Comment on lines +1762 to +1768
return Object.values(mapping).some((target) => {
// A mapping value is either a reference or a bare schema name.
const $ref = target.startsWith("#/") ? target : `${SCHEMA_REFERENCE_PREFIX}${target}`;
try {
// resolveSchemaReference indexes into the parsed document, so the resolved variant
// and the schema being converted are the same object when they are the same schema.
return context.resolveSchemaReference({ $ref }) === schema;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning

Identity comparison (=== schema) is doing a lot of load-bearing work here. It depends on resolveSchemaReference returning the exact same object instance from the parsed document rather than a clone/merged copy, and on schema still being that same instance by the time convertSchemaObject runs (no upstream {...schema} spread, e.g. from maybeInjectDescriptionOrGroupName or nullable/description normalization). If either assumption breaks, the guard silently returns false and the bug returns without any test signal.

A more robust alternative: resolve the base's discriminator.mapping and compare the mapping target $ref against the name of the schema currently being converted (breadcrumbs/wrapAsNullable context already carries it in most call sites), falling back to identity only if the name is unavailable. At minimum, add a comment asserting the no-copy invariant, or a test fixture that exercises the variant with a description on the allOf wrapper (which is a common trigger for schema cloning).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in af4faa1 — the invariant now has test signal and a comment, though the audit came out cleaner than the warning suggests.

I traced the copy paths rather than assuming. Inside convertSchemaObject there are exactly two reassignments of schema: { type: schema } when it arrives as a string, and { ...schema, format: "binary" } — and that one lives inside the if (schema.type === "string") block, which returns before any allOf handling is reached. So no path that reaches the guard has copied the schema, and resolveSchemaReference indexes into this.document and returns the stored object rather than a clone or merge.

maybeInjectDescriptionOrGroupName was a good thing to point at, but it operates on the converted result on the way out, not on the input schema, so it cannot affect identity going in.

Took the fixture suggestion, which was the useful half. The fixture gains two more variants carrying description and title alongside the allOf — sibling keys being the plausible trigger for a copy. Both stay properties: {} rather than collapsing to an alias, so identity survives them, and if that ever stops being true the snapshot fails rather than silently reverting. I also wrote the invariant down where the comparison happens.

I did not take the name-based alternative. breadcrumbs is not a reliable identifier here — it is a display path, inline schemas can produce colliding names, and swapping a precise check for a fuzzy one to guard against a copy that does not happen would trade a real property for a heuristic. Identity is exact; when it fails it fails closed, back to today's behaviour.

Comment on lines +1758 to +1762
const mapping = context.resolveSchemaReference(element).discriminator?.mapping;
if (mapping == null) {
return false;
}
return Object.values(mapping).some((target) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

This resolves every mapping entry on each call, and it runs on both short-circuit checks for every single-element allOf in the document — O(variants) resolutions per variant, so O(n²) for a union with many members. For large specs with big discriminated unions this could get noticeable. Consider caching the resolved mapping-target set per base $ref on the context, or bailing early once a match is found (the .some already short-circuits, but resolution order means the average is half the mapping).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in af4faa1.

The resolved mapping targets are now memoized per base schema, so a union's variants resolve the mapping once between them instead of once each:

const discriminatedVariantsByBase = new WeakMap<OpenAPIV3.SchemaObject, ReadonlySet<OpenAPIV3.SchemaObject>>();

Keyed on the base object rather than its $ref string so two documents parsed in one process cannot collide, and weak so it does not outlive the parse. Membership is then a Set lookup.

On the "bail early" half — .some already short-circuits, but that only helps the matching variant; the non-matching ones still walked the whole mapping. Caching is what actually removes the quadratic term, so I did that instead.

}
return Object.values(mapping).some((target) => {
// A mapping value is either a reference or a bare schema name.
const $ref = target.startsWith("#/") ? target : `${SCHEMA_REFERENCE_PREFIX}${target}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion

target.startsWith("#/") misses two legal mapping forms: an absolute/relative external ref (common.yaml#/components/schemas/Foo) and a URI-ish ref. Those will get mangled into #/components/schemas/common.yaml#/... and then fail to resolve — caught by the catch, so it just falls back to false (i.e. the old buggy behaviour) rather than crashing. Probably fine to leave, but target.includes("#/") would be a cheap widening.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into this one and the widening would not buy anything, so I left the condition and documented the behaviour instead.

resolveSchemaReference opens with schema.$ref.substring(2) — it assumes the ref starts with #/ and indexes into the current document from there. Hand it common.yaml#/components/schemas/Foo and it splits on the mangled remainder, walks off the document, and returns a sentinel. So target.includes("#/") would pass the external ref through unmangled only to have the resolver mangle it one line later. Both spellings reach the same place.

Also worth correcting one detail: it is not the catch that saves us, because resolveSchemaReference never throws. An unresolvable reference returns a fresh {"x-fern-type": "unknown"} object, and a fresh object is by construction not the schema being converted, so the guard returns false and the short-circuit stays — which is the pre-existing behaviour for that variant, exactly as you say.

That made the try/catch dead code, so af4faa1 removes it and replaces it with a comment stating why an unresolvable target degrades safely. Following external references is a limitation of this resolver generally — every other resolveSchemaReference call in this file shares it — so it is not something this guard can fix locally.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

…keys

Review follow-ups on the variant guard.

Memoize the resolved mapping targets per base schema. The guard runs for every
single-element allOf in the document and each of a union's variants asks the same
question of the same base, so the resolution work was quadratic in the number of
variants. Keyed on the base object rather than its $ref so two documents in one
process cannot collide, and weak so it does not outlive the parse.

Drop the try/catch around resolveSchemaReference. It never throws: an unresolvable
reference returns a fresh `x-fern-type: unknown` sentinel, which by construction is
not the schema being converted, so an unresolvable mapping target - a reference into
another document, which this resolver cannot follow - already degrades to leaving the
short-circuit in place. Say so in a comment instead of catching an error that cannot
happen.

Record why membership by object identity is sound, and cover it: the fixture gains
two more variants carrying `description` and `title` alongside the allOf, since
sibling keys are the plausible trigger for a schema being copied before conversion.
Both stay objects, so the identity holds.
@wiebren wiebren changed the title fix(openapi importer): keep a discriminated union variant an object when it is a bare allOf reference fix(openapi): keep a discriminated union variant an object when it is a bare allOf reference Aug 28, 2026
@wiebren

wiebren commented Aug 28, 2026

Copy link
Copy Markdown
Author

CI notes, since two checks were red.

Lint PR title — mine, fixed. I had written the scope as fix(openapi importer):, and
openapi importer is not in the allowed list. Retitled to fix(openapi):, now passing.

test-ete — not caused by this PR. Three test files fail, in two unrelated groups:

generate/generate.test.ts and v2/generate.test.ts fail with:

⚠ You are not logged in to Fern.
  To authenticate, run: 'fern auth login' or set the FERN_TOKEN environment variable

These need FERN_TOKEN, which a pull request from a fork does not receive. Every recent
fern-api-owned PR I checked passes test-ete (#17573, #17575); mine is the only fork PR in
that set. Nothing in this change touches auth.

validate/validate.test.ts is pre-existing on main. I checked it out at b5df1878e — the
exact commit this branch is based on, with no changes applied — and ran the same file:

validate failures
b5df1878e (clean main) 5
this branch 5

Same five: simple, docs, no-api, no-generator, and check with --api resolves all APIs referenced by docs. The diff is only in the summary line — the snapshot expects
All checks passed (the cli-v2 wording) and the run produces
Found 0 errors and 1 warning in __ELAPSED__ seconds. Run fern check --warnings ... (the
cli v1 wording from printCheckReport.ts). The validation result is unchanged; it is the
report format that drifted from the snapshot. test-ete is skipped on pushes to main, so
this drift would not have shown up there.

Happy to refresh those validate snapshots here if you would like, but it is unrelated to
this change and I would rather not bury an importer fix under an ete snapshot update — say
the word and I will do it either way.

For what it is worth, the checks that do exercise this change are green: compile,
boundaries, lint, biome, and the full openapi matrix, including the
discriminated-union-with-nested-oneof shard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant