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:

Decision tree for choosing allOf, oneOf or a single schema Start by asking whether the endpoint returns more than one distinct shape. If no, use one flat schema. If yes, ask whether a payload must satisfy every subschema at once. If yes, use allOf. If it must satisfy exactly one, use oneOf with a discriminator. More than one distinct shape? yes no one flat schema Must a payload satisfy them all at once? all — AND one — XOR allOf base + extension per variant oneOf + discriminator tag pinned with const

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.

allOf builds each variant; oneOf selects between them A base schema at the bottom feeds into three variant boxes via allOf. The three variants feed upward into a single oneOf union box at the top, which is what the endpoint references. Notification (oneOf) exactly one branch may match EmailNotification allOf: base + { to_email } channel: const "email" SmsNotification allOf: base + { msisdn } channel: const "sms" WebhookNotification allOf: base + { target_url} channel: const "webhook" NotificationBase id · channel · created_at · status
# 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:

required lists accumulate across allOf branches On the left, a base schema requiring to_email and a variant branch requiring msisdn both apply to one SMS instance, so validation fails on the missing to_email. On the right, to_email has moved into the email branch and the same instance validates. Misplaced required — fails Required beside its property — passes base branch required: [to_email] sms branch required: [msisdn] base branch required: [id,channel] sms branch required: [msisdn] SMS instance { channel: "sms", msisdn } ✗ missing to_email SMS instance { channel: "sms", msisdn } ✓ validates

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.