OpenAPI Schema Composition

Part of the API Design Fundamentals & Architecture reference. Schema composition is the part of an OpenAPI document where a clean resource model either survives contact with real payloads or collapses into copy-pasted property lists. This section covers the four composition keywords — allOf, oneOf, anyOf, not — plus discriminator, $ref reuse, and the exact shapes each one produces in a generated TypeScript, Python or Go client.

Problem framing

An API that started with three flat resources ends up, eighteen months later, with a Payment object that is really four different objects wearing the same name: a card payment with a last4, a bank transfer with an iban, a wallet payment with a provider, and a credit note with none of them. If the spec models that as one schema with every field optional, the contract stops describing anything. Clients cannot tell which fields travel together, validators accept nonsense like a card payment carrying an iban, and generated SDKs hand the caller a type where every property is string | undefined.

Composition is the fix. allOf factors out the fields every variant shares, oneOf states that a payload is exactly one variant, and discriminator tells tooling which variant to expect from a single property value. Done well, the spec becomes a precise union and the generated client becomes a discriminated union the compiler can narrow. Done badly — inline schemas with no component key, a oneOf whose branches overlap, a discriminator with no mapping — the same keywords produce ambiguous validation and unusable client types. Composition sits directly on top of the resource boundaries drawn in Resource Modeling Best Practices; if those boundaries are wrong, no amount of allOf will rescue the document.

How the composition keywords differ

The four keywords are all applicators: they do not describe a value directly, they apply other schemas to it and combine the results. The diagram shows how a single payload flows through each one.

How allOf, oneOf, anyOf and not evaluate subschemas Four columns. allOf requires every subschema to match and merges them. oneOf requires exactly one subschema to match. anyOf requires at least one subschema to match. not requires the subschema to fail. allOf oneOf anyOf not schema A ✓ schema B ✓ schema A ✓ schema B ✗ schema A ✓ schema B ✓ schema A ✗ valid when ALL match fields merge → inheritance valid when EXACTLY 1 matches → variant union valid when 1 OR MORE match → overlap ok valid when NONE match exclusion → guard rail

The practical rule: allOf is and, oneOf is exclusive or, anyOf is inclusive or, and not is a negative constraint you reach for rarely. Only oneOf and anyOf accept a discriminator.

Spec definition

Here is the canonical composed payload: a shared base, three variants that extend it with allOf, and a union with an explicit discriminator mapping. Every construct below is valid OpenAPI 3.1.0 and validates under JSON Schema 2020-12.

# openapi.yaml (OpenAPI 3.1.0) — composed payment payload
openapi: 3.1.0
info:
  title: Payments API
  version: "3.0.0"
components:
  schemas:
    PaymentBase:
      type: object
      required: [id, method, amount_minor, currency]
      properties:
        id:            { type: string, format: uuid }
        method:        { type: string }        # the discriminator property
        amount_minor:  { type: integer, minimum: 1 }
        currency:      { type: string, pattern: "^[A-Z]{3}$" }
        captured_at:   { type: [string, "null"], format: date-time }

    CardPayment:
      allOf:
        - $ref: "#/components/schemas/PaymentBase"
        - type: object
          required: [last4, network]
          properties:
            method:  { const: card }           # pins the variant
            last4:   { type: string, pattern: "^[0-9]{4}$" }
            network: { type: string, enum: [visa, mastercard, amex] }

    BankTransferPayment:
      allOf:
        - $ref: "#/components/schemas/PaymentBase"
        - type: object
          required: [iban]
          properties:
            method: { const: bank_transfer }
            iban:   { type: string, minLength: 15, maxLength: 34 }

    WalletPayment:
      allOf:
        - $ref: "#/components/schemas/PaymentBase"
        - type: object
          required: [provider]
          properties:
            method:   { const: wallet }
            provider: { type: string, enum: [apple_pay, google_pay] }

    Payment:
      oneOf:
        - $ref: "#/components/schemas/CardPayment"
        - $ref: "#/components/schemas/BankTransferPayment"
        - $ref: "#/components/schemas/WalletPayment"
      discriminator:
        propertyName: method
        mapping:
          card:          "#/components/schemas/CardPayment"
          bank_transfer: "#/components/schemas/BankTransferPayment"
          wallet:        "#/components/schemas/WalletPayment"

Four details make this composition work rather than merely parse. The discriminator property method lives on the base, so it is present on every variant. Each variant pins it with const, so the oneOf branches are provably mutually exclusive and a validator can never match two at once. Every variant is a named component, so generators emit CardPayment rather than InlineObject3. And the mapping is explicit, so a client library does not have to guess that the value bank_transfer corresponds to the schema named BankTransferPayment.

Standard alignment

Composition keywords come from JSON Schema; discriminator is the one OpenAPI-specific addition. Knowing which layer a keyword belongs to tells you which validator will enforce it.

Keyword Defined by Clause Enforced by a plain 2020-12 validator?
allOf JSON Schema Core 2020-12 §10.2.1.1 Yes
oneOf JSON Schema Core 2020-12 §10.2.1.3 Yes
anyOf JSON Schema Core 2020-12 §10.2.1.2 Yes
not JSON Schema Core 2020-12 §10.2.1.4 Yes
$ref with siblings JSON Schema Core 2020-12 §8.2.3.1 Yes (3.1 only)
const JSON Schema Validation 2020-12 §6.1.3 Yes
discriminator OpenAPI Specification 3.1 §4.8.25 No — tooling hint only

That last row is the one teams get wrong. A validator that knows nothing about OpenAPI will still enforce your oneOf, but it will ignore discriminator entirely. The discriminator earns its keep in generators and hand-written client parsers, which use it to pick a branch in one property lookup instead of trial-validating every branch. Since OpenAPI 3.1 adopted the 2020-12 dialect wholesale, the composition semantics are identical to plain JSON Schema — see OpenAPI 3.0 vs 3.1 Schema Dialect Differences for the keywords that did change.

Implementation: validating a composed payload server-side

Composition is only useful if the server enforces it on the way in. Compile the composed schema once at boot and validate request bodies against it, so a card payload carrying an iban is rejected before it reaches domain code.

// validate-payment.ts — Express middleware enforcing the composed contract
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020";
import addFormats from "ajv-formats";
import type { Request, Response, NextFunction } from "express";
import spec from "./openapi.json" assert { type: "json" };

const ajv = new Ajv2020({ allErrors: true, strict: false, discriminator: false });
addFormats(ajv);

// Register every component so internal $refs resolve.
for (const [name, schema] of Object.entries(spec.components.schemas)) {
  ajv.addSchema(schema as object, `#/components/schemas/${name}`);
}

const validatePayment: ValidateFunction = ajv.getSchema(
  "#/components/schemas/Payment",
)!;

export function paymentBody(req: Request, res: Response, next: NextFunction) {
  if (validatePayment(req.body)) return next();

  // Map ajv errors onto the shared Problem+JSON contract.
  res.status(422).type("application/problem+json").json({
    type: "urn:api:errors:v1:payment-shape-invalid",
    title: "Payment payload did not match any known variant",
    status: 422,
    errors: (validatePayment.errors ?? []).map(e => ({
      pointer: e.instancePath || "/",
      detail: e.message ?? "invalid",
    })),
  });
}

The error body follows RFC 7807 Problem+JSON Implementation so a composition failure looks like every other validation failure to the caller. Note discriminator: false on the ajv instance: ajv’s discriminator support requires oneOf branches with a const tag and forbids allOf indirection, so for this shape plain oneOf evaluation is both correct and simpler.

The Python equivalent compiles the same document with the standards validator:

# validate_payment.py — resolve internal $refs and validate against the union
import json
from referencing import Registry, Resource
from jsonschema import Draft202012Validator

spec = json.load(open("openapi.json"))
registry = Registry().with_resources(
    (f"#/components/schemas/{name}", Resource.from_contents(schema, default_specification=Draft202012Validator.META_SCHEMA))
    for name, schema in spec["components"]["schemas"].items()
)

validator = Draft202012Validator(
    spec["components"]["schemas"]["Payment"], registry=registry
)

def validate_payment(body: dict) -> list[dict]:
    """Return a list of {pointer, detail} problems; empty means valid."""
    return [
        {"pointer": "/" + "/".join(str(p) for p in err.absolute_path), "detail": err.message}
        for err in validator.iter_errors(body)
    ]

Implementation: reading the discriminator in a client

On the client side the discriminator turns a union into a single property lookup. This is the shape a generated SDK produces, and the shape to hand-write when you are not generating.

Discriminator-driven branch selection in a client A JSON response enters the client. The client reads the method property, looks it up in the discriminator mapping, and dispatches to the matching variant parser: card, bank transfer or wallet. An unknown value falls through to an unknown-variant handler. JSON response { "method": … } read propertyName look up mapping O(1), nore-validation "card" CardPayment parser "bank_transfer" BankTransferPayment "wallet" WalletPayment parser unknown forward-compat
// payment.ts — the discriminated union a generator emits from the spec above
export type Payment = CardPayment | BankTransferPayment | WalletPayment;

const PARSERS = {
  card: parseCard,
  bank_transfer: parseBankTransfer,
  wallet: parseWallet,
} as const;

export function parsePayment(raw: unknown): Payment {
  const method = (raw as { method?: string })?.method;
  const parser = method && PARSERS[method as keyof typeof PARSERS];
  if (!parser) {
    // Forward compatibility: a new variant must not crash an old client.
    throw new UnknownPaymentVariantError(method ?? "<missing>");
  }
  return parser(raw);
}

// TypeScript narrows on the literal `method`, so this compiles without casts:
export function describe(p: Payment): string {
  switch (p.method) {
    case "card":          return `card ending ${p.last4}`;
    case "bank_transfer": return `transfer from ${p.iban.slice(0, 8)}`;
    case "wallet":        return `${p.provider} wallet`;
  }
}

The UnknownPaymentVariantError branch matters more than it looks. Adding a fourth payment method is an additive spec change, but an old client that assumes exhaustive parsing will throw an uncaught TypeError deep in a map callback. Treat an unrecognised discriminator value as a known, handled outcome — the same forward-compatibility discipline that breaking change detection applies at the spec level.

Edge-case handling

required inside allOf branches. required applies to the whole instance, not the branch that declares it. Listing required: [iban] in the bank-transfer branch is correct, but listing it in the base while only some variants have the field makes every variant invalid. Keep each required list next to the properties it constrains.

additionalProperties: false and allOf. These two do not compose. additionalProperties only sees properties declared in the same schema object, so a base with additionalProperties: false rejects every field a variant adds. If you need closed objects, apply unevaluatedProperties: false on the outermost schema instead — 2020-12 added it precisely because it does see through applicators.

Deep $ref chains. Composition across many files is fine, but circular references (NodechildrenNode) break naive generators even though validators handle them. Keep a cycle breaker: reference the cycle through a named component so the generator can emit a self-referential type rather than expanding forever.

Empty oneOf branches. A branch of {} matches everything, which silently makes the union non-exclusive: any payload matching a real branch also matches the empty one, so oneOf fails with two matches. Lint for it.

Nullable variants. In 3.1, type: [string, "null"] inside one branch is fine, but a null payload against a oneOf of object schemas matches nothing and produces a bare “must match exactly one” error. Add an explicit { type: "null" } branch if null is legal.

Validation and testing patterns

Two cheap gates catch nearly every composition mistake before merge. First, a Spectral rule that refuses an undiscriminated object union:

# .spectral.yaml — composition governance rules
extends: ["spectral:oas"]
rules:
  union-needs-discriminator:
    description: A oneOf/anyOf over object schemas must declare a discriminator.
    message: "{{property}} is a union of object schemas without a discriminator"
    severity: error
    given: "$.components.schemas[?(@.oneOf || @.anyOf)]"
    then:
      field: discriminator
      function: truthy

  discriminator-mapping-complete:
    description: A discriminator must map every branch explicitly.
    severity: error
    given: "$.components.schemas[?(@.discriminator)].discriminator"
    then:
      field: mapping
      function: truthy

  no-inline-composition:
    description: Compose named components, not inline schemas in request bodies.
    severity: warn
    given: "$.paths[*][*].requestBody.content[*].schema"
    then:
      field: "$ref"
      function: truthy

Second, a table-driven contract test that proves each sample payload matches exactly one branch. This is the test that catches an accidentally-overlapping union, which no linter can see:

// payment-union.test.ts — every fixture must match exactly one oneOf branch
import { describe, it, expect } from "vitest";
import Ajv2020 from "ajv/dist/2020";
import spec from "./openapi.json" assert { type: "json" };

const ajv = new Ajv2020({ strict: false, allErrors: true });
for (const [name, schema] of Object.entries(spec.components.schemas)) {
  ajv.addSchema(schema as object, `#/components/schemas/${name}`);
}

const BRANCHES = ["CardPayment", "BankTransferPayment", "WalletPayment"] as const;

const FIXTURES = [
  { name: "card", body: { id: "…", method: "card", amount_minor: 500, currency: "GBP", last4: "4242", network: "visa" } },
  { name: "transfer", body: { id: "…", method: "bank_transfer", amount_minor: 500, currency: "GBP", iban: "GB33BUKB20201555555555" } },
  { name: "wallet", body: { id: "…", method: "wallet", amount_minor: 500, currency: "GBP", provider: "apple_pay" } },
];

describe("Payment union", () => {
  for (const fixture of FIXTURES) {
    it(`${fixture.name} matches exactly one branch`, () => {
      const matches = BRANCHES.filter(b =>
        ajv.getSchema(`#/components/schemas/${b}`)!(fixture.body),
      );
      expect(matches).toHaveLength(1);
    });
  }
});

Run both in the same workflow that lints the rest of the document, as described in Contract Linting & Governance.

SDK generation impact

The same composed spec produces three quite different client shapes, and knowing which one you get changes how you write the spec.

Composition keywords mapped to generated client types Three rows. allOf becomes an intersection type in TypeScript, a subclass in Python and an embedded struct in Go. oneOf with a discriminator becomes a discriminated union, a tagged model union and an interface with a type switch. anyOf becomes a loose union, an untagged union and an any-typed field. keyword TypeScript Python (pydantic) Go allOf merge / inherit Base & Variant (intersection) class V(Base) (subclass) struct { Base } (embedding) oneOf + discriminator exclusive variant A | B | C narrows on tag Annotated[Union, Field(discriminator)] interface + switch on tag field anyOf overlapping A | B (no narrow) manual guards Union[A, B] first-match wins map[string]any or json.RawMessage

Two consequences are worth designing around. In statically typed languages, anyOf degrades badly — Go generators frequently fall back to json.RawMessage because there is no exclusive tag to switch on, which pushes parsing back onto the caller. And in every language, a oneOf without a discriminator forces the generated client to trial-parse each branch in order, so the first branch that happens to fit wins even when it is the wrong one. If you take a single rule from this section: every object union gets a discriminator with a complete mapping. Downstream, that choice is what makes SDK method signatures readable and stable across versions — see Client SDK Generation Pipelines.

Anti-patterns quick-reference

Anti-pattern Recommended approach
One giant schema with every field optional Split into variants; compose with oneOf + discriminator
oneOf branches without a pinned tag value Pin the discriminator property with const in each branch
discriminator without a mapping Always write the mapping explicitly; do not rely on name inference
Inline composed schemas in request bodies Extract to components/schemas and $ref them
additionalProperties: false on an allOf base Use unevaluatedProperties: false on the outermost schema
anyOf used for mutually exclusive variants Use oneOf so an overlapping payload fails loudly
Copy-pasted shared fields across variants Factor into a base schema referenced by allOf
Client switch with no default branch Handle the unknown-variant case for forward compatibility

FAQ

When should I use allOf instead of oneOf in an OpenAPI schema?

Use allOf when a payload must satisfy every referenced schema simultaneously — that is how you express “this variant is a PaymentBase plus these extra fields”. Use oneOf when a payload must satisfy exactly one of several shapes, which is how you express “a payment is a card payment or a transfer or a wallet payment, never two at once”. A useful test: if removing one of the referenced schemas would make a valid payload invalid, you need allOf; if it would make an invalid payload valid, you need oneOf.

Does the OpenAPI discriminator perform validation?

No. The discriminator is a tooling hint. It tells a generator or a hand-written parser which branch to select for a given property value, so the client can dispatch in one property lookup instead of trial-validating every branch. It never relaxes the underlying oneOf or anyOf constraint, and a plain JSON Schema validator ignores it completely. For the hint to be usable, the discriminator property must be required on every branch and should be pinned with const so the union is provably exclusive.

Why does my generated client produce an anonymous inline type for a composed schema?

Generators name types after the component key they were referenced from. A schema composed inline inside a request body, response or parameter has no component key, so the generator invents something like InlineObject1 or PaymentsPostRequest. Worse, those names change whenever paths are reordered, so every regeneration produces a spurious diff. Extract the schema into components/schemas under a deliberate name and reference it with $ref; the generated type name then becomes part of your public SDK surface and stays stable.

Is anyOf ever the right choice over oneOf?

Yes, when overlapping matches are legitimate rather than a modelling error. A search parameter that accepts either an ISO date or a relative expression such as -7d may satisfy both a format: date schema and a looser pattern schema, and there is no harm in that. But if the variants are meant to be mutually exclusive, anyOf actively hides bugs: a payload that matches two branches still validates, so an ambiguous contract ships silently. Default to oneOf and downgrade to anyOf only with a stated reason.