Skip to main content

Conditional Schemas with if, then and else

Symptom: a schema with an if/then block rejects payloads that should pass, or accepts payloads that break a rule you were certain the schema enforced. The behaviour looks random until you notice the pattern: it depends on whether the tested property was present at all. This guide is part of JSON Schema Keywords for API Payloads.

Root Cause: An Absent Property Satisfies the Condition

A JSON Schema constrains what is present. A subschema saying properties: { method: { const: "card" } } places no requirement on an object that has no method at all — so it passes, the then branch applies, and a payload you expected to fall into else is validated against the wrong rules.

# BROKEN — an object with no `method` matches the condition vacuously.
Payment:
  type: object
  required: [amount]
  properties:
    method: { type: string, enum: [card, bank_transfer] }
    amount: { type: integer, minimum: 1 }
    card_last4: { type: string }
    iban: { type: string }
  if:
    properties: { method: { const: card } }
  then:
    required: [card_last4]
$ echo '{"amount":100}' | ajv validate -s payment.yaml -d /dev/stdin --spec=draft2020
data must have required property 'card_last4'

The payload has no method, so it should not be a card payment at all — yet it is being told to supply card details.

The three paths through an unguarded conditional A card payload correctly enters the then branch and a transfer payload correctly enters else, but a payload with no method property also enters then, because a subschema that only constrains a property is satisfied when the property is missing. method: "card" method: "bank_transfer" no method at all if: method is "card" unguarded then requires card_last4 else requires iban The dashed path is the bug: an absent property satisfies a constraint about that property.

Step 1: Guard the Condition With required

One line fixes it. Inside the if, require the property you are testing.

Payment:
  type: object
  required: [amount, method]              # also make it required overall, if it is
  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}$' }
  if:
    required: [method]                    # ← the guard: absent method fails the condition
    properties: { method: { const: card } }
  then:
    required: [card_last4]
  else:
    required: [iban]

Why this works: with required: [method] inside the condition, an object lacking method fails the if, falls into else, and gets the error it deserves — a missing iban, or better, a missing method from the outer required.

Step 2: Prefer dependentRequired for Presence Rules

Many conditionals are really the simplest possible rule: if this field is present, these must be too. Draft 2019-09 added a keyword that says exactly that, with no vacuous-truth hazard and a much better error message.

The same rule, two ways Expressed as a conditional the rule needs an if with a required guard, a then and careful reading; expressed with dependentRequired it is a single mapping, and the error message names both properties. as a conditional if: { required: [billing_address] } then: { required: [postcode, country] } error: "must have required property" — with no hint why as dependentRequired dependentRequired: billing_address: [postcode, country] error names both properties and no vacuous-truth hazard
# Instead of three nested conditionals:
BillingDetails:
  type: object
  properties:
    billing_address: { type: string }
    postcode: { type: string }
    country: { type: string }
    vat_number: { type: string }
    vat_country: { type: string }
  dependentRequired:
    billing_address: [postcode, country]
    vat_number: [vat_country]
$ echo '{"billing_address":"1 High St"}' | ajv validate -s billing.yaml -d /dev/stdin --spec=draft2020
data must have property 'postcode' when property 'billing_address' is present

That error names both properties and the relationship between them — something an if/then block cannot produce.

Step 3: Keep Independent Rules Independent

When several conditions apply to one object, putting them in separate allOf entries keeps each rule readable and makes each violation report separately.

Subscription:
  type: object
  required: [plan, billing_period]
  properties:
    plan: { type: string, enum: [free, pro, enterprise] }
    billing_period: { type: string, enum: [monthly, annual] }
    seats: { type: integer, minimum: 1 }
    purchase_order: { type: string }
    trial_ends_at: { type: string, format: date-time }
  allOf:
    # Rule 1: paid plans must declare a seat count.
    - if:
        required: [plan]
        properties: { plan: { enum: [pro, enterprise] } }
      then:
        required: [seats]
    # Rule 2: enterprise annual billing needs a purchase order.
    - if:
        required: [plan, billing_period]
        properties:
          plan: { const: enterprise }
          billing_period: { const: annual }
      then:
        required: [purchase_order]
    # Rule 3: only the free plan may carry a trial end date.
    - if:
        required: [trial_ends_at]
      then:
        properties: { plan: { const: free } }

Rule 3 shows the inverse shape: the condition tests presence and the consequence constrains a different field. That reads awkwardly as a conditional but is genuinely a rule about the payload, and encoding it beats leaving it in a comment.

Independent rules report independently One payload is evaluated against three separate allOf conditionals; two pass and one fails, and the failure names its own missing property rather than a combined union error. payload plan: enterprise billing: annual, seats: 40 rule 1: paid plans need seats seats present — passes rule 2: enterprise annual purchase_order missing — fails rule 3: trial only on free no trial date — condition false one precise error "must have required property 'purchase_order'"

Step 4: Mirror the Rule in the Runtime Validator

The schema is one enforcement point; the application’s own validator is another. A conditional that exists in only one of them will be violated through the other. In Zod the same rule is a refinement:

const payment = z.object({
  method: z.enum(["card", "bank_transfer"]),
  amount: z.number().int().positive(),
  card_last4: z.string().regex(/^\d{4}$/).optional(),
  iban: z.string().optional(),
}).superRefine((value, ctx) => {
  if (value.method === "card" && !value.card_last4) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["card_last4"],
                   message: "card payments must include the last four digits" });
  }
  if (value.method === "bank_transfer" && !value.iban) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["iban"],
                   message: "bank transfers must include an IBAN" });
  }
});

Note the explicit path on each issue: it is what lets the API return a field-level error rather than a whole-object one, matching the error contract the rest of the API publishes.

Verification

// conditional.test.ts — three cases per rule, not two
const validate = ajv.compile(paymentSchema);

test("card payments require the last four digits", () => {
  expect(validate({ method: "card", amount: 100 })).toBe(false);
  expect(validate.errors![0].params).toMatchObject({ missingProperty: "card_last4" });
});

test("bank transfers require an IBAN", () => {
  expect(validate({ method: "bank_transfer", amount: 100 })).toBe(false);
  expect(validate.errors![0].params).toMatchObject({ missingProperty: "iban" });
});

test("a payload with no method fails on method, not on card details", () => {
  // The case an unguarded conditional gets wrong.
  expect(validate({ amount: 100 })).toBe(false);
  const missing = validate.errors!.map((e) => e.params?.missingProperty);
  expect(missing).toContain("method");
  expect(missing).not.toContain("card_last4");
});

The third test is the regression guard. It fails on the unguarded schema and passes on the fixed one, which makes it the test worth writing first.

Edge Cases and Caveats

  • else is optional and often wrong. An if/then with no else means “when the condition holds, also satisfy this” — frequently what you want. Adding an else asserts something about every other payload, which is a much stronger claim than it looks.
  • Conditionals and unevaluatedProperties. Properties matched inside a then branch count as evaluated, so unevaluatedProperties: false at the top level behaves correctly with conditionals — unlike additionalProperties, which does not see them at all.
  • Generated types ignore conditionals. Most generators emit every property as optional and lose the rule entirely. If compile-time enforcement matters, model the alternatives as a discriminated union instead, as covered in choosing between oneOf, anyOf and allOf.
  • Error message quality. A failing then reports the inner failure with no mention of the condition that triggered it, so “must have required property ‘iban’” gives no hint that the rule applies because method was bank_transfer. Where the rule is subtle, catch it in application code and emit a message that explains the dependency.
  • OpenAPI 3.0 has no conditionals. if, then, else and dependentRequired arrived with the 2019-09 and 2020-12 drafts, so they are available in OpenAPI 3.1 and not in 3.0. On 3.0 the fallback is a discriminated oneOf.

Conditionals and API Evolution

A conditional rule is part of the contract, so changing one carries the same compatibility questions as changing a field — and they are less obvious because the rule is not visible in a field list.

Adding a conditional that requires a new field is breaking, even though the field itself is optional in the type sense: a payload that validated yesterday now fails. Relaxing a conditional is safe, because it can only accept more. And changing which branch a payload falls into — by altering the if — is the most dangerous edit of all, since a payload that previously satisfied then is now judged against else with no field having changed.

The practical consequence is that conditionals belong in the same diff review as everything else, and a spec diff tool that reports “if/then changed” without saying how is not enough. Where a conditional guards something important, a fixture that exercises both branches is the cheapest way to make the change visible: the fixture starts failing, and the reviewer sees which side moved.

Frequently Asked Questions

Why does my then branch apply when the condition should be false?

Because the if subschema passed vacuously. A subschema that only constrains a property is satisfied when that property is absent, so an object missing the tested field matches the condition. Add required for the tested property inside the if block.

When should I use dependentRequired instead?

Whenever the rule is simply presence-implies-presence: if billing_address is present, postcode must be too. dependentRequired says that in one line, with no vacuous-truth hazard and a far clearer error message.

Can I chain several conditions?

Yes, by nesting the next if inside the else, or by putting several independent if blocks inside an allOf. The allOf form is easier to read because each rule stands alone, and it reports each violation separately.

Do conditionals affect generated types?

Barely — most generators ignore if, then and else entirely and emit the union of all properties as optional. If the distinction matters at compile time, model it as a discriminated union instead.

Does the if subschema itself produce validation errors?

No. A failing if simply means the condition did not hold, and the else branch applies. Only failures inside then or else are reported, which is why an error mentioning a rule you did not expect usually means the condition matched unexpectedly.