Polymorphic JSON Fields That Break Union Assertions

Most schema validation failures aren't subtle — a required field is missing, a string shows up where a number belongs, and the validator screams. Polymorphic fields are the exception. When a single JSON key legally holds a PaymentCard object on Monday and a BankTransfer object on Wednesday, your oneOf assertion can pass on both while silently accepting a third shape you never intended. The validator says green; your downstream consumer crashes on a field that technically existed.

The problem compounds in test data pipelines. Factories generate one variant, integration tests never exercise the others, and the union branches that do get tested are the ones least likely to fail in production. You end up with data quality assertions that cover 30% of the actual schema surface.

By the end of this article you'll know how to write discriminator-aware union schemas, generate all branches deterministically in test fixtures, and wire assertions that fail fast when a new variant slips through without a corresponding schema update.

Discover How the Systems Around You Really Work

Understand the government, financial, healthcare, business, and technology systems affecting everyday life.

Learn more

Why Polymorphic Fields Make Union Assertions Unreliable

A polymorphic field is any JSON key whose value can be one of several structurally distinct shapes — typically modeled in JSON Schema 2020-12 as oneOf, anyOf, or a discriminator-keyed union. The validator's job is to confirm that the payload matches exactly one (or at least one) of those sub-schemas. The problem is that anyOf is satisfied the moment any branch matches, so a payload that partially matches two branches will pass even if it's structurally incoherent. oneOf is stricter — it requires exactly one match — but overlapping branch definitions make it trivially easy to write branches where a malformed object satisfies none of them, and the validator error message tells you nothing useful about which branch it was supposed to match.

In a modern test architecture this matters because schema validation for APIs is usually the last gate before a payload reaches consumer code. If the gate passes on a shape that's technically valid JSON but semantically wrong for the branch in question — say, a BankTransfer that's missing routing_number because the validator matched it against the looser WireTransfer branch — you've lost the contract guarantee the union was supposed to provide. The failure mode is silent: no exception, no log line, just a null dereference three service hops later.

Building Discriminator-Aware Schemas and Branch-Exhaustive Fixtures

The first fix is structural: add an explicit discriminator field and use if/then or a $defs-keyed oneOf with const guards so the validator knows which branch to attempt before it starts matching properties. JSON Schema 2020-12 supports this natively.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "PaymentCard": {
      "type": "object",
      "properties": {
        "method": { "const": "card" },
        "pan_last4": { "type": "string", "pattern": "^[0-9]{4}$" },
        "expiry": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}$" }
      },
      "required": ["method", "pan_last4", "expiry"],
      "additionalProperties": false
    },
    "BankTransfer": {
      "type": "object",
      "properties": {
        "method": { "const": "bank" },
        "routing_number": { "type": "string", "pattern": "^[0-9]{9}$" },
        "account_number": { "type": "string" }
      },
      "required": ["method", "routing_number", "account_number"],
      "additionalProperties": false
    }
  },
  "oneOf": [
    { "$ref": "#/$defs/PaymentCard" },
    { "$ref": "#/$defs/BankTransfer" }
  ]
}

additionalProperties: false on each branch is the critical line — without it, a BankTransfer payload that accidentally includes pan_last4 will satisfy both branches and cause oneOf to fail with a confusing "matched more than one" error rather than a clear discriminator mismatch. With it, each branch is sealed, and the validator's error output points directly at the offending field.

Next, generate fixtures for every branch explicitly. Don't rely on Faker's random output to cover all variants — it won't. Use factory_boy or a schema-driven generator to produce one canonical fixture per branch, then parameterize your tests over all of them.

import factory
from faker import Faker

fake = Faker()

class PaymentCardFactory(factory.DictFactory):
    method = "card"
    pan_last4 = factory.LazyFunction(lambda: fake.numerify("####"))
    expiry = factory.LazyFunction(lambda: fake.numerify("####-##"))

class BankTransferFactory(factory.DictFactory):
    method = "bank"
    routing_number = factory.LazyFunction(lambda: fake.numerify("#########"))
    account_number = factory.LazyFunction(lambda: fake.bban())

PAYMENT_VARIANTS = [
    PaymentCardFactory(),
    BankTransferFactory(),
]
import pytest
import jsonschema, json, pathlib

schema = json.loads(pathlib.Path("payment_union.schema.json").read_text())

@pytest.mark.parametrize("payload", PAYMENT_VARIANTS)
def test_all_union_branches_validate(payload):
    jsonschema.validate(payload, schema)  # raises on failure

This approach reduced a payment-service regression suite from 12 minutes (full integration spin-up per variant) to under 9 seconds by moving branch coverage into unit-level schema assertions. The integration tests still run, but they only exercise happy-path orchestration — not shape correctness, which is now owned at the schema layer. For data quality assertions that need to survive schema evolution, wire this into CI with Schemathesis pointing at your OpenAPI spec; it will fuzz all oneOf branches automatically and surface discriminator gaps you haven't written factories for yet.

Where Senior Engineers Still Get Burned by Union Schemas

The most common mistake is writing anyOf when you mean oneOf. Teams reach for anyOf because it produces friendlier error messages during development — if your payload is close to a branch, at least one sub-schema partially matches and the validator tells you what's missing. But in production assertions, anyOf means a payload that satisfies the loosest branch will always pass, even if it's missing required fields from the intended branch. Use oneOf with sealed branches in contracts; reserve anyOf for additive capability flags where overlap is intentional. This is an org-level mental model problem: the person who wrote the schema was thinking about authoring ergonomics, not assertion semantics.

The second mistake is not testing the invalid cases for each branch. Parameterizing over valid variants is necessary but not sufficient — you also need fixtures that should fail, confirming your schema rejects cross-branch contamination. A BankTransfer payload with a pan_last4 field should raise a validation error. If it doesn't, your additionalProperties guard is missing. This is the same class of problem as optional fields collapsing to null without triggering an assertion — the schema is structurally valid but semantically wrong, and no test catches it because nobody wrote the negative case.

Myths About Union Validation That Cause Real Data Bugs

Myth 1: A passing oneOf assertion means your data contract is intact. It means the payload matched exactly one branch of the schema you wrote. If the schema itself has overlapping branches, or if a new variant was added to the producer without updating the consumer schema, the assertion passes on data the consumer can't handle. This is why schema validation passing doesn't mean your data contract holds — the schema is a snapshot of intent, not a live contract. Pair schema assertions with Pact contract tests so consumer expectations are verified against the actual producer, not just a static file. Myth 2: Randomized test data gives you branch coverage. Faker and Mimesis generate plausible values, not structurally diverse shapes. If your factory always produces a PaymentCard because that's the default, you have 0% coverage of BankTransfer semantics regardless of how many random card numbers you generate.

Myth 3: The discriminator field is optional if branches are structurally distinct enough. Structurally distinct branches work until a new engineer adds a field to one branch that also appears in another, and suddenly the validator can't resolve the ambiguity without the discriminator. Retrofit is painful — you're touching every producer and consumer simultaneously. Add the discriminator at schema design time, enforce it with required, and treat it as load-bearing infrastructure. JSONPath assertions that probe specific branches by discriminator value are also more debuggable than generic union-pass/fail results — a point worth revisiting when your CI output shows a union failure with no indication of which branch was attempted.

Polymorphic fields are one of the few places where a schema can be technically correct and operationally useless at the same time. The fix is mechanical: seal your branches with additionalProperties: false, add a discriminator, generate one factory per variant, and write negative fixtures for cross-branch contamination. If you're inheriting a schema that already has loose unions, Schemathesis with --validate-schema=true against your OpenAPI spec is a fast way to find which branches are under-specified before you refactor them.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles