The Null Equivalence Principle for JSON APIs

Most APIs should treat omitted, null, and empty values consistently to reduce complexity and ambiguity. When you truly need distinct semantics—PATCH (RFC 7396), GraphQL nullability, or OpenAPI 3.1 union types—document it, enforce it, and make the exception explicit.

TL;DR — Unless your domain proves otherwise, treat omitted, null, and empty as the same “no value.” Prefer omitting fields. Document and enforce the rule end-to-end.

The Three-Way Equivalence

Three ways to say “there is no meaningful value” routinely show up in JSON:

  1. Omitted

json { "name": "Justin Time" }

  1. Explicit null

json { "name": "Justin Time", "homePhone": null }

  1. Empty string (for string fields)

json { "name": "Justin Time", "homePhone": "" }

In most APIs, these are semantically equivalent. Treating them as such keeps your contracts simple and your payloads small.

The Pragmatic Argument

Simplification

  • Fewer conditionals in clients and servers
  • Lower ambiguity in contracts
  • Fewer bugs from drift (client A sends null, client B omits)

Performance

  • Omitting fields trims payload size with zero loss of meaning
  • Parsers do less work; caches compress better

Where Equivalence Must Break

There are legit exceptions. Respect them—don’t stumble into them:

  • HTTP PATCH with JSON Merge Patch (RFC 7396):
  • Omitteddon’t change
  • nulldelete the field
  • Recommendation: if you need “clear vs. leave alone,” support a PATCH endpoint that uses Merge Patch and document the semantics. See RFC 7396.

  • OpenAPI 3.1 + JSON Schema: null is a real JSON Schema type and not the same as “absent.” Only model a null when you truly need tri-state behavior. See OpenAPI 3.1 release notes, the specification, and JSON Schema’s null type.

  • GraphQL: Fields are nullable by default; non-null (!) is an explicit promise. Newer work on semantic nullability sharpens error/null handling. If you’re a GraphQL backend, design nullability intentionally, not accidentally. See Apollo’s nullability guide and the GraphQL Conf 2024 talk on Semantic Nullability.

Practical Policy You Can Ship

  1. Default: Treat omitted, null, and empty string as the same “no value.”
  2. Responses: Prefer omitting fields over returning null.
  3. Requests: Normalize on ingress (coalesce null/"" to omitted) unless a field’s contract explicitly defines different meaning.
  4. Schemas: In OpenAPI 3.1, avoid type: ["string","null"] unless you genuinely need a null value. Use a single type and make the property optional by default.
  5. Updates: When you need “clear,” use Merge Patch (PATCH) or an explicit command (e.g., clearHomePhone: true).
  6. Tooling: Enforce and test equivalence at the edges: serializers strip empties; validators normalize inputs; add contract tests.

Node Patterns (No-Go on Go)

1) Input Normalization Middleware (Express/Fastify)

Normalize requests so the rest of your app only sees present with value or absent.

// normalize.ts
import { NextFunction, Request, Response } from "express";

function prune(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(prune);
  if (obj && typeof obj === "object") {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (v === null || v === undefined) continue; // drop nullish
      if (typeof v === "string" && v.trim() === "") continue; // drop empty strings
      out[k] = prune(v);
    }
    return out;
  }
  return obj;
}

export function normalizeNoValue(req: Request, _res: Response, next: NextFunction) {
  // Only mutate a copy of the body to avoid surprises
  if (req.is("application/json") && req.body && typeof req.body === "object") {
    req.body = prune(req.body);
  }
  next();
}

Wire it up:

import express from "express";
import { normalizeNoValue } from "./normalize";

const app = express();
app.use(express.json());
app.use(normalizeNoValue);
// ...routes

Tune the heuristic if your domain legitimately needs "" as meaningful content.

2) Validation — Ajv (JSON Schema)

Use a plain JSON Schema with a single type. Don’t union in null unless you really need it.

// person.schema.ts
export const personSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    name: { type: "string", minLength: 1 },
    homePhone: { type: "string", minLength: 1 }, // optional by default
  },
  required: ["name"],
} as const;
// validator.ts
import Ajv from "ajv";
import addFormats from "ajv-formats";
import { personSchema } from "./person.schema";

const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
export const validatePerson = ajv.compile(personSchema);

Because the normalization middleware drops null and "", clients can send any of { omitted | null | "" } and your schema stays simple.

3) Validation — Zod (TypeScript-first)

import { z } from "zod";

export const Person = z.object({
  name: z.string().min(1),
  homePhone: z.string().min(1).optional(),
});

export type Person = z.infer<typeof Person>;

If you must allow null for some field, make it explicit:

const UserPrefs = z.object({
  nickname: z.string().min(1).optional(),
  timezone: z.string().optional().nullable(), // accepts string | null | undefined
});

4) Response Serialization (prune on the way out)

// response.ts
export function pruneForWire<T>(v: T): T {
  return JSON.parse(
    JSON.stringify(v, (_key, value) => {
      if (value === null || value === undefined) return undefined; // drop
      if (typeof value === "string" && value.trim() === "") return undefined; // drop empty strings
      return value;
    })
  );
}

// usage
res.json(pruneForWire(model));

Contracting It in OpenAPI 3.1

Default equivalence — make fields optional and document the policy.

# openapi.yaml (excerpt)
openapi: 3.1.0
info:
  title: Contacts API
  version: 1.0.0
paths:
  /people:
    post:
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1 }
                homePhone: { type: string, minLength: 1 }
              required: [name]
      responses:
        '201':
          description: created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Person'
components:
  schemas:
    Person:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        homePhone:
          type: string
          description: >
            Optional. If no meaningful value exists, the field is **omitted**.
            Clients MAY send `null` or empty string; the server treats them as equivalent to omission.
      required: [id, name]

Explicit tri-state — only where you truly need it.

# Field intentionally supports explicit null
components:
  schemas:
    UserPrefs:
      type: object
      properties:
        timezone:
          type: [string, 'null']
          description: >
            `null` means the user explicitly cleared their timezone. Absent means unchanged/default.

Updates: When You Need “Clear vs. Leave Alone”

Prefer HTTP PATCH with JSON Merge Patch semantics; document that null removes a member and omission leaves it alone.

PATCH /people/123
Content-Type: application/merge-patch+json

{ "homePhone": null }

Result: the server deletes homePhone.

If you can’t support PATCH, expose an explicit command:

{ "clearHomePhone": true }

The Recalcitrant’s Burden

If you insist on distinct semantics across your API:

  1. Document per-field meaning (omitted vs. null vs. empty)
  2. Specify handling rules (requests and responses)
  3. Prove real value with concrete use cases
  4. Guarantee consistency across every service
  5. Train every developer and client

If you can’t clear that bar, don’t make your future self maintain it.


References & Further Reading


Closing — The burden of proof sits with those insisting on the distinction. If you can’t show concrete value that outweighs complexity, adopt the Null Equivalence Principle, document it, and move on to harder problems.