← Back to Blog
·9 min read

JSON Schema: The Missing Layer of Your API Contracts

Most APIs document what their JSON responses look like. Almost none of them enforce it. JSON Schema is the IETF-standardized solution that most teams ignore until a field gets silently renamed in production and costs them an incident. That's a fixable problem.

The contract problem nobody talks about

Here is a scenario that has happened to every team working with APIs: a backend engineer renames a field from user_name to usernameto match a new database schema. They update the API response. They update the documentation (sometimes). They do not update the consumers, because they forgot they existed, or because the consumers are in a different repository, or because the change seemed minor. Three days later, a customer service ticket arrives: "user profile shows blank name."

The fix takes five minutes. The diagnosis takes forty. The post-mortem concludes with "we should have better communication between teams" — which is not an engineering solution, it is a hope. The actual engineering solution is JSON Schema validation enforced in CI, and it is embarrassingly underused.

What JSON Schema actually is

JSON Schema (currently at draft 2020-12, published as an IETF internet standard) is a vocabulary for annotating and validating JSON documents. It describes the shape of a JSON value: what types fields must be, which fields are required, what additional fields are permitted, what format strings must match. A schema is itself a JSON document, which means it is readable, diffable, versionable, and parseable by the same tools you already use.

The core keywords cover the most common constraints:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "username": { "type": "string", "minLength": 3, "maxLength": 50 },
    "email": { "type": "string", "format": "email" },
    "role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
    "createdAt": { "type": "string", "format": "date-time" },
    "metadata": { "type": "object" }
  },
  "required": ["id", "username", "email", "role"],
  "additionalProperties": false
}

The additionalProperties: false line is the one most teams skip and later regret. Without it, the schema allows any field to be added. With it, an unexpected field in the response triggers a validation error — which is exactly what you want when a downstream API adds a field your consumer does not expect or, worse, removes one it does.

The keywords that matter most

Beyond the basics, a handful of keywords unlock real expressive power:

$ref and $defs — JSON Schema supports composition through references. Define a UserSummary schema once in $defs and reference it from a dozen places with {"$ref": "#/$defs/UserSummary"}. This is the equivalent of a shared type definition — change it in one place and validation everywhere updates.

anyOf, oneOf, allOf — For polymorphic responses. An API that returns either a SuccessResponse or an ErrorResponse can use anyOf to express that constraint. This is how OpenAPI 3.x represents discriminated unions in its JSON Schema subset.

if/then/else — Conditional validation. If the type field is "business", then taxId is required. If it is "individual", it is forbidden. You can express genuinely complex business rules in a declarative schema.

Validating in JavaScript with ajv

ajv (Another JSON Validator) is the fastest and most widely used JSON Schema validator for JavaScript. It compiles schemas to optimized validation functions, handles all draft versions, and runs in both Node.js and browsers. Adding it to an API client is straightforward:

import Ajv from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true });
addFormats(ajv); // adds "email", "date-time", "uri", etc.

const userSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    username: { type: 'string' },
    email: { type: 'string', format: 'email' },
  },
  required: ['id', 'username', 'email'],
  additionalProperties: false,
};

const validate = ajv.compile(userSchema);

async function getUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  if (!validate(data)) {
    // validate.errors contains detailed error information
    throw new Error(`Invalid API response: ${ajv.errorsText(validate.errors)}`);
  }

  return data; // TypeScript doesn't know the type yet — see next section
}

The key insight is where to put this validation. It belongs at the API boundary — the moment your application receives data from an external source — not deep inside business logic where the invalid data has already caused side effects. Validate at the edge, fail loudly, and log the error with the raw response so you can diagnose what the API actually returned.

Auto-generating schemas from TypeScript types with Zod

The manual approach — writing both a TypeScript interface and a JSON Schema — creates two sources of truth that inevitably diverge. The better approach is to derive one from the other. Zod, the TypeScript-first schema library, lets you define a schema once and get both runtime validation and static type inference for free:

import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';

// Define once
const UserSchema = z.object({
  id: z.number().int(),
  username: z.string().min(3).max(50),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
});

// Get TypeScript type for free
type User = z.infer<typeof UserSchema>;

// Get JSON Schema for documentation/CI
const jsonSchema = zodToJsonSchema(UserSchema, 'User');

// Validate at runtime
async function getUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const raw = await response.json();
  return UserSchema.parse(raw); // throws ZodError with detailed message if invalid
}

This approach has a compounding benefit: when a backend engineer changes the API response shape, they update the Zod schema, which automatically updates the TypeScript types consumed throughout the application. The type errors surface immediately in the IDE, before any code runs. This is the closest thing to a compile-time API contract that JavaScript/TypeScript developers have.

Schema validation in CI: the real payoff

Runtime validation catches errors in production. Schema validation in CI catches them before deployment. The pattern is simple: record a known-good API response, save it as a fixture, and validate it against the current schema in every CI run. When the API changes shape, the CI fixture fails before anyone ships anything.

// tests/api-contracts/user.contract.test.ts
import { UserSchema } from '../../schemas/user.schema';
import userFixture from './fixtures/user.json';

test('user API response matches schema', () => {
  const result = UserSchema.safeParse(userFixture);
  if (!result.success) {
    throw new Error(result.error.toString());
  }
});

// In package.json scripts:
// "test:contracts": "vitest run tests/api-contracts"
// Add to CI pipeline before deploy

For teams using OpenAPI, the schema-first approach goes further: define the schema in the OpenAPI spec, generate client code from it (with tools like openapi-typescript or openapi-generator), and validate mock responses in CI. Any change to the API spec that breaks existing consumers surfaces as a CI failure before it reaches any environment.

The argument for mandatory schema validation on financial and user-data APIs

This is the section where I will be direct: any API that moves money or processes user data should have JSON Schema validation at both the producer and consumer sides. Not as a nice-to-have. Not "we'll add it when we have time." As a hard engineering requirement.

Here is why. When a financial API silently changes its response shape, the failure modes are not "blank user profile." They are double-charged transactions. Silent payment failures. Orders processed at the wrong amount because a field moved from a nested object to the root level and your code picked up a default value of zero. These are not hypothetical. They happen, they are expensive, and they are preventable.

Schema validation is not a testing strategy. It is a boundary condition that should be as automatic as null checks. The tooling is mature, the runtime overhead is negligible (ajv is extraordinarily fast), and the alternative — trusting that APIs will never change without notice — is a bet that distributed systems will punish you for taking.

Published May 17, 2026 · By the utili.dev Team