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.
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.
// 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 (Node → children → Node) 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.
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.
Related
- API Design Fundamentals & Architecture — up-link: the parent reference covering resource modeling, HTTP semantics and contract-first design
- allOf vs oneOf for Polymorphic Payloads — the decision guide for choosing between the two applicators on a real payload
- Discriminator Mapping in Generated Clients — how the mapping surfaces in TypeScript, Python and Go SDKs
- Reusable Schema Components and $ref Cycles — structuring a component library that generators can resolve
- Contract Linting & Governance — the sibling section that enforces these composition rules in CI
- Client SDK Generation Pipelines — how composed schemas travel through generation into published SDKs