Skip to main content

JSON Schema Keywords for API Payloads

JSON Schema is the type system underneath OpenAPI 3.1, AsyncAPI and every JSON-validating runtime library. Most API payload bugs come from a handful of keywords used with the wrong expectations: format that validates nothing, additionalProperties that fights composition, oneOf that reports useless errors. This guide extends Schema Design & Validation Patterns and covers which keyword to reach for, and what your validator will actually do with it.

When to Use This Approach

Work at the keyword level when:

  • You are writing schemas by hand rather than generating them, and want the constraints to be enforceable rather than documentary.
  • A payload has conditional rules — a field required only when another has a particular value — that currently live in prose or in application code.
  • Strict validation is rejecting payloads that look valid, or accepting payloads that clearly are not.
  • You are moving from OpenAPI 3.0 to 3.1 and need to know which keywords changed meaning.
  • Generated types are wider than the schema implies, which usually traces back to composition keywords.
Four families of keyword Structural keywords describe shape, assertion keywords constrain values, composition keywords combine schemas, and annotation keywords describe without validating. Confusing the last group with the second is the usual source of surprise. structural type properties items required additionalProperties what shape is it? assertions enum, const minimum, maximum minLength, maxLength pattern multipleOf always enforced composition allOf, anyOf, oneOf not if / then / else $ref, $defs unevaluatedProperties combines the others annotations title, description examples, default deprecated format readOnly, writeOnly validate nothing by default

format sitting in the fourth column, not the second, is the single most consequential fact on this page.

Prerequisites

Examples use JSON Schema draft 2020-12, Ajv 8.17 with ajv-formats 3.0, and OpenAPI 3.1.1.

npm install ajv@8.17 ajv-formats@3.0
// validate.ts — the configuration the rest of this guide assumes
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";

export const ajv = addFormats(new Ajv2020({
  strict: true,          // reject schemas with unknown or contradictory keywords
  allErrors: true,       // collect every failure, not just the first
  validateFormats: true, // WITHOUT this, `format` is decorative
}));

Step 1: Pin the Draft, in Both Places

A schema whose draft the validator guesses is a schema whose behaviour you cannot predict — exclusiveMinimum alone changed from a boolean to a number between drafts.

# A standalone schema file declares its own draft.
$schema: https://json-schema.org/draft/2020-12/schema
$id: https://schemas.example.com/order.yaml
type: object

Inside an OpenAPI 3.1 document the draft is implied by the specification version, which is one of the strongest reasons to move off 3.0: on 3.0 you are writing a dialect that resembles draft-04 with OpenAPI-specific additions like nullable, and every tool interprets the edges slightly differently.

Step 2: Constrain Values With Keywords That Assert

components:
  schemas:
    CreateOrder:
      type: object
      required: [customer_id, items, currency]
      additionalProperties: false
      properties:
        customer_id:
          type: string
          pattern: '^cus_[a-zA-Z0-9]{16}$'      # enforced, always
        currency:
          type: string
          enum: [EUR, GBP, USD]                 # enforced, always
        items:
          type: array
          minItems: 1
          maxItems: 100                         # a denial-of-service control
          items: { $ref: '#/components/schemas/LineItem' }
        discount_percent:
          type: integer
          minimum: 0
          maximum: 100                          # enforced, always
        callback_url:
          type: string
          format: uri                           # ONLY enforced if you enable formats
          maxLength: 2048                       # so add a real constraint too

The pairing on callback_url is the pattern worth copying: keep format for documentation and tooling, and add a keyword that asserts unconditionally next to it. The string format guide covers which formats are worth asserting and which are traps.

Step 3: Close the Object With the Right Keyword

additionalProperties: false is the correct way to close a plain object, and the wrong way to close a composed one. It cannot see properties defined by a sibling subschema, so an allOf that composes a base and an extension rejects the base’s own fields.

# WRONG — rejects `id`, which the base schema defines.
ValidationError:
  allOf:
    - $ref: '#/components/schemas/Problem'
  additionalProperties: false
  properties:
    errors: { type: array, items: { $ref: '#/components/schemas/FieldError' } }

# RIGHT — unevaluatedProperties considers everything the composition evaluated.
ValidationError:
  allOf:
    - $ref: '#/components/schemas/Problem'
  unevaluatedProperties: false
  properties:
    errors: { type: array, items: { $ref: '#/components/schemas/FieldError' } }
Why composition needs unevaluatedProperties additionalProperties only sees the properties declared alongside it, so fields from a composed base schema look unknown. unevaluatedProperties sees every property any subschema in the composition matched. additionalProperties: false errors — seen title — rejected status — rejected detail — rejected it cannot see what the allOf branch defined unevaluatedProperties: false errors — seen title — seen status — seen typo_field — rejected only genuinely unknown fields are refused

The failures this causes are covered in detail in fixing additionalProperties false validation failures.

Step 4: Express Conditional Rules the Validator Can Check

A rule that lives only in a description will be violated. if/then/else moves it into the schema:

Payment:
  type: object
  required: [method, amount]
  properties:
    method: { type: string, enum: [card, bank_transfer] }
    amount: { type: integer, minimum: 1 }
    card_last4: { type: string, pattern: '^[0-9]{4}$' }
    iban: { type: string, pattern: '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$' }
  # Card payments must carry the last four digits; transfers must carry an IBAN.
  if:
    properties: { method: { const: card } }
    required: [method]
  then:
    required: [card_last4]
  else:
    required: [iban]

The required: [method] inside if is not redundant. Without it, a payload omitting method entirely makes the if schema pass vacuously and the then branch applies — the single most common mistake with these keywords, unpacked in conditional schemas with if, then and else.

Step 5: Extract What Repeats

components:
  schemas:
    Money:
      type: object
      required: [amount, currency]
      additionalProperties: false
      properties:
        amount: { type: integer, description: Minor units. }
        currency: { type: string, enum: [EUR, GBP, USD] }

    LineItem:
      type: object
      required: [sku, quantity, unit_price]
      additionalProperties: false
      properties:
        sku: { type: string, pattern: '^[A-Z0-9-]{3,32}$' }
        quantity: { type: integer, minimum: 1, maximum: 999 }
        unit_price: { $ref: '#/components/schemas/Money' }

Inside an OpenAPI document, use components.schemas so tooling can name the generated type. In a standalone schema file, $defs is the equivalent. The mechanics are the same as for any reusable component.

Keyword Reference

Keyword Applies to Enforced by default Use it for
type any yes the basic shape
enum / const any yes a closed value set, discriminators
pattern string yes identifier shapes, codes
minLength / maxLength string yes bounding input size
minimum / maximum number yes range rules
multipleOf number yes currency steps, quantised values
minItems / maxItems array yes bounding work per request
uniqueItems array yes set semantics
required object yes mandatory fields
additionalProperties object yes closing a plain object
unevaluatedProperties object yes (2019-09+) closing a composed object
format string no — opt in documentation, plus optional assertion
readOnly / writeOnly any no direction hints for generators
default any no documentation; never a substitute for a value
deprecated any no signalling removal

Verification

// schema.test.ts — assert the schema rejects what it should
const validate = ajv.compile(createOrderSchema);

test("rejects an unknown property", () => {
  expect(validate({ customer_id: "cus_0123456789abcdef", currency: "EUR",
                    items: [item], customer_name: "typo" })).toBe(false);
  expect(validate.errors?.[0].keyword).toBe("additionalProperties");
});


test("enforces the array bound", () => {
  expect(validate({ customer_id: "cus_0123456789abcdef", currency: "EUR",
                    items: Array(101).fill(item) })).toBe(false);
  expect(validate.errors?.[0].keyword).toBe("maxItems");
});

test("asserts format because formats are enabled", () => {
  expect(validate({ customer_id: "cus_0123456789abcdef", currency: "EUR",
                    items: [item], callback_url: "not a url" })).toBe(false);
  expect(validate.errors?.[0].keyword).toBe("format");
});
Which layer enforces which keyword Structural and assertion keywords are enforced by the request validator at the edge; composition keywords also shape the generated types at build time; annotations reach only the documentation. build time type, properties, required oneOf with a discriminator allOf, $ref shapes the generated types request time pattern, enum, minimum maxItems, maxLength additionalProperties rejects the payload never enforced description, examples default, deprecated format, unless opted in reaches humans and tooling only

That third test is the one to keep permanently: it fails the day somebody constructs the validator without ajv-formats, which is otherwise a silent downgrade.

Troubleshooting

A schema that looks right rejects valid payloads

Nine times out of ten it is additionalProperties: false inside a composition. Switch to unevaluatedProperties: false and re-run. If the schema does not compose, check for a required entry naming a property that a $ref’d subschema defines rather than this one.

format: email accepts obviously invalid addresses

Format assertion is off. Install and register ajv-formats, and set validateFormats: true. Note that even with assertion on, format checks are deliberately permissive — for anything security-relevant, add a pattern or validate in application code.

Strict mode complains about a schema that used to work

Ajv’s strict mode rejects unknown keywords and contradictory combinations that earlier drafts silently ignored — for example type: string alongside minimum. The complaint is almost always pointing at a real mistake; fix the schema rather than disabling strict mode.

Generated TypeScript types are wider than the schema

Composition keywords are the usual cause: a bare oneOf produces an untagged union the compiler cannot narrow. Add a discriminator, as described in modeling polymorphic types with oneOf.

if/then applies when it should not

The if subschema passed vacuously because the property it tests is absent. Add required for the tested property inside the if block so an absent field fails the condition rather than satisfying it.

Reading a Validation Error Back to Its Keyword

Most of the time you meet these keywords in reverse: an error arrives and you work out which keyword produced it. Learning the mapping saves the diagnostic step entirely.

An error naming additionalProperties means a property was present that the schema did not declare at that level — and the “at that level” is where composition trips people, because a property declared in an allOf branch is not declared at the level holding the keyword. An error naming unevaluatedProperties means the same thing after composition, so it is a real unknown field rather than an artifact of the structure.

An invalid_type or type error against undefined is a missing required field wearing a confusing name; the schema said the property must be a string and nothing arrived. Mapping that to a REQUIRED code before it reaches a client is one of the highest-value translations a validation layer can do.

A oneOf error with no path is a union that failed to resolve, not a field that failed to validate — the validator could not decide which branch applied, so it has nothing more specific to report. Adding a discriminator converts that into an error inside the correct branch, with a usable path.

A format error only ever appears when format assertion was switched on, so its presence is itself informative: it confirms the validator is configured the way you believe it is. Its absence on an obviously invalid value is the signal that assertion is off.

Where the Keywords Stop

Three constraints recur in real payloads and none of them are expressible in JSON Schema, so knowing to stop looking saves time.

Cross-record uniqueness — “this email is not already registered” — requires state the schema cannot see. uniqueItems covers uniqueness within an array and nothing else.

Referential existence — “this customer id refers to a real customer” — is the same problem. The schema can constrain the shape of the identifier; only the application can check that it resolves.

Authorisation-dependent rules — “only an admin may set this field” — depend on the caller, which is not part of the payload. These belong in the service layer, and attempting to encode them in the schema produces a schema that has to be chosen per caller, which defeats the point of having one.

For each of these, the schema’s job is to get the value into a shape the application can check cheaply: a well-formed identifier, a bounded array, a closed object. The check itself lives elsewhere, and saying so in the description saves the next reader from searching for a keyword that does not exist.

Validating the Schema Itself

A schema is a document with its own correctness, and two checks catch mistakes that would otherwise surface as confusing validation behaviour.

Compile it with strict mode on. Ajv’s strict mode rejects unknown keywords, contradictory type constraints, and combinations that cannot be satisfied — a minimum on a string, a required naming a property no branch declares, a misspelled additionalProperties. Each of those is silently ignored by a permissive validator, which means the constraint you thought you wrote is not being applied at all. Strict mode turns them into build failures.

Test the schema against fixtures, in both directions. A schema with only positive fixtures is barely tested: the interesting question is what it rejects. For each constraint that matters, keep one payload that violates it and assert the keyword that fires. Those tests fail loudly when a keyword is dropped during a refactor, which is exactly when a constraint quietly stops being enforced.

Together they give a schema the same treatment as code — a compiler and a test suite — which is appropriate, because a schema is executable and its bugs reach production the same way.

Generating Schemas Rather Than Writing Them

Everything above assumes hand-authored schemas. Where they are generated from types or from decorators, the keyword-level advice still applies but the lever moves: you configure the generator rather than editing the output.

Two things are worth checking in generated output regardless of the tool. Whether objects are closed — most generators leave additionalProperties unset, which is the wrong default for request bodies. And whether constraints survive — a TypeScript type carries no information about ranges, lengths or patterns, so a schema generated purely from types will contain none of them, and the resulting document validates far less than it appears to.

The practical arrangement is to generate the structure and annotate the constraints, either through decorators the generator understands or through a post-processing step that merges a hand-maintained constraints file into the generated schema. Either way, review the generated document rather than trusting it — the gap between “compiles” and “validates” is exactly where this class of bug lives.

Choosing How Much Schema Is Enough

A schema can always be tightened further, and past a point the tightening costs more than it returns. The useful stopping rule is to constrain everything that changes what your code does with the value, and to stop at everything that does not.

A maxItems changes what your code does — it bounds the work a request can demand. A pattern on an identifier changes what your code does — a malformed identifier will not resolve. A minLength: 1 on a display name usually does not: an empty string renders as nothing, which is the same outcome as a missing field, and rejecting it buys little while excluding a caller who had nothing to send.

The exception is anything security-relevant, where the calculus reverses: constrain first and relax on evidence, because the cost of an unbounded value there is not a rendering artifact.

Where to Start on an Existing Schema

Given a schema already in production, three changes deliver most of the benefit for the least risk. Close the request bodies with the correct keyword for their structure, which catches typos immediately and breaks nobody who was sending well-formed requests. Add a bound to every array and every string, which costs nothing at runtime and removes a denial-of-service surface. And enable format assertion with a test that proves it is on, so the constraints the document advertises are the constraints it applies. Everything else on this page can follow at leisure.

Frequently Asked Questions

Which JSON Schema draft should an API use?

Draft 2020-12, because OpenAPI 3.1 aligns with it and the two stop disagreeing. If you are on OpenAPI 3.0 you are effectively on a modified draft-04 subset, and moving to 3.1 removes a whole category of tooling surprises.

Does format actually validate anything?

By default, no — format is annotation-only in the specification, and validators must be told to assert it. In Ajv you enable it by installing ajv-formats. Assuming format is enforced when it is not is one of the most common causes of bad data passing validation.

What is the difference between $defs and components.schemas?

$defs is the JSON Schema keyword for local definitions; components.schemas is OpenAPI’s registry for reusable schemas. Inside an OpenAPI document use components.schemas so tooling finds and names them; use $defs in standalone schema files.

Should I set additionalProperties false everywhere?

On request bodies, usually yes — rejecting unknown fields catches typos and prevents silent data loss. On response schemas, usually no, because it makes every additive change breaking for a strict consumer.

How do I express a field that is required only sometimes?

With if, then and else, or with a discriminated oneOf when the condition is a type tag. Avoid encoding the rule in prose only — a constraint a validator cannot check will be violated within a release or two.

Are unevaluatedProperties worth using?

Yes, when composing with allOf. additionalProperties cannot see through composition and rejects properties a sibling subschema defined; unevaluatedProperties can, which makes it the correct keyword for closing a composed object.