DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

Codegen on oneOf: Discriminated Unions and Where Clients Flatten Them

Quick answer: A oneOf schema with a discriminator expresses a tagged union, and good generators turn it into a closed sum type you narrow by checking the tag, but a number of generators flatten it into a permissive object or merge all branches into optional fields, which silently destroys the type safety that made the schema useful. The fix is to model the discriminator explicitly and consistently, put the property name in the discriminator mapping, and verify the generated output before you rely on it; if your generator flattens the union, you either configure the correct option, switch generators, or add a thin hand-written layer that reintroduces the narrowing. Treating the generated type as trustworthy without inspecting it is how you end up with code that compiles while allowing combinations the API rejects.

Polymorphism is a natural fit for real APIs: a payment might be a card, a bank transfer, or a wallet, and each variant carries its own fields. In OpenAPI the clean way to express this is a oneOf list of member schemas plus a discriminator that names the property whose value selects the branch. That is enough information for a generator to emit a discriminated union, and in languages with sum types the result is excellent: the compiler forces you to handle every variant and narrowing on the tag exposes the right fields. The problem is that generators differ substantially in how they interpret the same construct, and the failure usually does not announce itself as an error; it appears as a weaker type that accepts more than the contract intends.

How generators differ on oneOf

The same schema can produce fundamentally different client shapes depending on the generator and its settings.

Output shape What you get Cost
Tagged union Exhaustive narrowing None, ideal
Merged object All fields optional Invalid combinations allowed
Base plus cast Manual casts Unsafe, runtime errors
any/unknown No checking Type safety lost

The merged object row is the most common silent failure: because every branch's properties become optional, the type permits a payload that mixes fields from two variants, and the mistake is only caught when the server rejects it, far from the code that constructed the request.

Model the discriminator correctly

The discriminator needs a property name and, when the field values do not match the schema names, an explicit mapping. A generator can only produce a clean tagged union if the schema makes the tag unambiguous.

Each member schema should declare the discriminator property as a literal or enum value, such as method: card, so the generated type carries the tag rather than a generic string. If the tag is just an open string, the generator cannot narrow on it and the union degrades toward a permissive object even in otherwise good toolchains.

Narrow the generated union in client code

When the generator emits a proper tagged union, the calling code should narrow on the discriminator and let the compiler enforce exhaustiveness, rather than casting to a concrete variant.

The never check is the practical payoff of the tagged union: when a new variant is added to the schema and regenerated, the compiler points at every switch that must handle it, which is the kind of guarantee a merged object cannot provide.

When the generator flattens, contain the damage

Inspect the generated output as part of the build, and if a generator merges the branches, first check for a flag that enables union generation; many have one that is off by default. If none exists, isolate the weak type at the boundary and write a small validation or factory layer that narrows into a hand-defined sum type, so the rest of the application never sees the permissive shape. The contract should keep the discriminator and member tags explicit regardless of the generator, because that is the source of truth a later, better generator can use without any schema change.

The practical rule is to treat oneOf plus discriminator as a promise the generator may or may not keep. Model the tag and mapping explicitly, regenerate and inspect the actual type, and only accept a tagged union; if the output is merged or untyped, contain it at the boundary with a hand-written narrowing layer. A type that compiles is not the same as a type that matches the contract, and polymorphic payloads are where that distinction produces the most avoidable runtime errors.

Frequently asked questions

What does a discriminator actually do?

It names the property whose value selects which member schema applies, giving the generator the information needed to emit a tagged union and the client a field to narrow on.

Why did my generator turn oneOf into one object with optional fields?

Some generators flatten unions by default or lack full sum-type support. Look for a union-generation option, and if none exists, add a boundary layer that narrows the weak type.

Do I need the mapping section?

When the discriminator values exactly match the member schema names, the mapping can be inferred; otherwise you must provide it explicitly so each tag resolves to the correct schema.

How do I guarantee every variant is handled?

Narrow on the tag in a switch and assign the default case to a never variable. Adding a new variant then causes a compile error at every switch that needs updating.

Is a generated type safe if it compiles?

Not necessarily. A merged object compiles while allowing invalid combinations, so inspect the generated shape against the contract rather than trusting compilation alone.

This is part of the The OpenAPI Code Generation Guide — start there for the full picture of generating reliable clients.

Top comments (0)