allOf vs oneOf for Polymorphic Payloads
This decision guide sits under OpenAPI Schema Composition in the API Design Fundamentals & Architecture reference. Both keywords combine subschemas, both appear in every polymorphic spec, and choosing the wrong one produces a document that still parses but describes the wrong contract — usually a union that accepts payloads it should reject, or an inheritance chain that rejects payloads it should accept.
The decision trigger
You are picking between the two keywords when an endpoint returns objects that are mostly alike. The table below maps the symptom you are seeing to the keyword that resolves it.
| Symptom / decision trigger | Keyword that fits |
|---|---|
| Every variant carries the same six fields plus a few of its own | allOf for the shared six |
| The response is a card payment or a bank transfer, never both | oneOf over the variants |
| A validator reports “must match exactly one schema, matched 2” | oneOf with non-exclusive branches — pin the tag |
| A validator reports “missing required property” on a field only one variant has | required misplaced in an allOf base |
| Generated client types are all-optional and unusable | flat schema that should be a oneOf union |
| A new variant needs adding without touching existing ones | oneOf list plus a new allOf variant |
The decision itself collapses to two questions asked in order:
Spec snippet: the shape that gets it right
The two keywords are not competitors — they operate at different levels. allOf builds a variant, oneOf chooses between built variants.
# openapi.yaml (OpenAPI 3.1.0) — polymorphic notification payload
components:
schemas:
NotificationBase:
type: object
required: [id, channel, created_at, status]
properties:
id: { type: string, format: uuid }
channel: { type: string }
created_at: { type: string, format: date-time }
status: { type: string, enum: [queued, sent, failed] }
EmailNotification:
allOf: # AND — base plus extension
- $ref: "#/components/schemas/NotificationBase"
- type: object
required: [to_email, subject] # required stays with its properties
properties:
channel: { const: email }
to_email: { type: string, format: email }
subject: { type: string, maxLength: 200 }
SmsNotification:
allOf:
- $ref: "#/components/schemas/NotificationBase"
- type: object
required: [msisdn]
properties:
channel: { const: sms }
msisdn: { type: string, pattern: "^\\+[1-9][0-9]{7,14}$" }
Notification:
oneOf: # XOR — exactly one variant
- $ref: "#/components/schemas/EmailNotification"
- $ref: "#/components/schemas/SmsNotification"
discriminator:
propertyName: channel
mapping:
email: "#/components/schemas/EmailNotification"
sms: "#/components/schemas/SmsNotification"
Step-by-step: choosing correctly
Step 1 — Enumerate the concrete variants
Write down the shapes the endpoint really returns, not the shapes your database tables suggest. If two “variants” differ only by which optional fields happen to be populated, they are one shape, and a union would add ceremony without adding meaning.
Step 2 — Extract only the genuinely shared core
A field belongs in the base when it appears in every variant with the same type and the same meaning. A reference field that means an email message-id in one variant and a carrier receipt in another is not shared; it is two fields with a colliding name.
Step 3 — Build each variant with allOf
Reference the base, then add an inline object with the variant’s own properties and its own required list. Keep the discriminator property pinned with const, which is what makes the later oneOf provably exclusive.
Step 4 — Declare the union with oneOf
List the variants and attach a discriminator whose mapping covers every branch. Reference the union — never an individual variant — from the response body, unless the endpoint genuinely only ever returns one shape.
Step 5 — Prove exclusivity with a fixture per variant
# From a blank terminal: validate one fixture per variant against the union.
npm init -y >/dev/null && npm i ajv@8 ajv-formats@3 js-yaml@4 >/dev/null
node -e '
const yaml = require("js-yaml"), fs = require("fs");
const Ajv = require("ajv/dist/2020"), addFormats = require("ajv-formats");
const doc = yaml.load(fs.readFileSync("openapi.yaml", "utf8"));
const ajv = addFormats(new Ajv({ strict: false, allErrors: true }));
for (const [n, s] of Object.entries(doc.components.schemas)) {
ajv.addSchema(s, `#/components/schemas/${n}`);
}
const fixtures = {
email: { id: "3f1e…", channel: "email", created_at: "2026-07-31T09:00:00Z",
status: "sent", to_email: "[email protected]", subject: "Receipt" },
sms: { id: "9c2a…", channel: "sms", created_at: "2026-07-31T09:00:00Z",
status: "queued", msisdn: "+447700900123" },
};
for (const [name, body] of Object.entries(fixtures)) {
const hits = ["EmailNotification", "SmsNotification"]
.filter(v => ajv.getSchema(`#/components/schemas/${v}`)(body));
console.log(name, "matched", hits.length, "branch(es):", hits.join(", "));
}
'
# email matched 1 branch(es): EmailNotification
# sms matched 1 branch(es): SmsNotification
Any count other than 1 is a modelling bug, not a validator quirk.
Standard compliance
allOf and oneOf are JSON Schema Core 2020-12 applicators — §10.2.1.1 and §10.2.1.3 respectively — and OpenAPI 3.1 uses them unchanged. Two clauses matter for polymorphism. First, applicators are evaluated against the whole instance: an allOf branch does not create a scope, so required lists from all branches accumulate. Second, oneOf is defined as valid “if and only if exactly one” subschema validates, so a payload matching two branches is an error even when both branches are individually correct.
discriminator is not part of JSON Schema at all; it is OpenAPI 3.1 §4.8.25, and a stock 2020-12 validator ignores it. That asymmetry is why const tags matter: they make the union exclusive at the schema level, so validation and code generation agree.
The accumulating-required rule is the clause that produces the most confusing error messages, so it is worth seeing evaluated:
Caching and safety implications
Polymorphic responses interact with caching in one specific way: if two variants share a URL and a client caches by URL alone, a Vary-less response can serve a card-payment body to a request that expected a wallet body after a server-side change. Because the variant is carried inside the body rather than in a header, the usual Vary mechanism does not help — the correct control is a strong ETag over the serialised body, so a changed variant produces a changed validator. See Statelessness & Caching Strategies for the conditional-request machinery this relies on.
SDK and codegen downstream effect
The keyword choice lands directly in the generated type. From the spec above, a TypeScript generator emits a narrowable discriminated union:
- // Flat schema: every field optional, nothing narrows
- export interface Notification {
- id?: string; channel?: string; to_email?: string;
- subject?: string; msisdn?: string; status?: string;
- }
+ // allOf + oneOf + discriminator: the compiler narrows on `channel`
+ export type Notification = EmailNotification | SmsNotification;
+
+ export interface EmailNotification extends NotificationBase {
+ channel: "email"; to_email: string; subject: string;
+ }
+ export interface SmsNotification extends NotificationBase {
+ channel: "sms"; msisdn: string;
+ }
The practical payoff is that if (n.channel === "email") n.subject compiles without a cast, and adding a third variant makes every non-exhaustive switch a compile error rather than a runtime surprise. The same spec in Python produces Annotated[Union[...], Field(discriminator="channel")], and in Go an interface plus a tag switch — the mapping is covered in Discriminator Mapping in Generated Clients.
Common mistakes
| Mistake | Correct approach |
|---|---|
Putting a variant-specific required field in the shared base |
Keep each required list in the same allOf branch as its properties |
Using oneOf where variants share every field and differ only in values |
Use one schema with an enum; a union adds no information |
Using allOf to express “either A or B” |
allOf means AND — use oneOf for alternatives |
Omitting const tags, then wondering why two branches match |
Pin the discriminator property with const in every variant |
| Referencing a single variant from the response instead of the union | Reference the oneOf union so new variants are additive |
Adding additionalProperties: false to the base |
Use unevaluatedProperties: false on the outermost schema instead |
FAQ
Can I use allOf and oneOf in the same schema?
Yes — the canonical polymorphic pattern uses both. Each variant is an allOf of a shared base plus its own extension object, and the payload schema is a oneOf over those variants. They never conflict because they work at different levels: allOf assembles one variant out of parts, oneOf picks one assembled variant. What you should not do is put a oneOf inside an allOf branch to mean “and also one of these” unless you have thought carefully about the combined required sets, because both keywords still apply to the whole instance.
Why does my oneOf report that two branches matched?
Because the branches are not mutually exclusive, which is almost always because a variant adds only optional properties. If SmsNotification declares msisdn as optional, an email payload — which has no msisdn — still satisfies it, so both branches match and the union fails. Fix it structurally rather than by loosening the union: pin the discriminator property with const in every branch, and mark each variant’s distinguishing field as required.
Does allOf merge required lists from every branch?
In effect, yes. Every subschema in an allOf is applied to the whole instance, so all required lists must be satisfied simultaneously. There is no notion of a field being required “only inside this branch”. This is exactly why a field that exists in one variant must not be listed as required in the shared base — doing so makes every other variant invalid, and the resulting error message (“missing required property”) points at the payload rather than at the schema bug that caused it.
Related
- OpenAPI Schema Composition — up-link: the section covering all composition keywords,
$refreuse and discriminators - API Design Fundamentals & Architecture — up-link: the parent reference for resource modeling and contract-first design
- Discriminator Mapping in Generated Clients — what the mapping turns into in TypeScript, Python and Go SDKs
- Reusable Schema Components and $ref Cycles — structuring the component library these variants live in
- Detecting Breaking Changes with oasdiff — proving that a new variant is additive before it ships