The Null Equivalence Principle for JSON APIs

Jens Haaning, Take the Money and Run

Jens Haaning, Take the Money and Run

Summary

My default: an omitted field, a null one, and an empty value all mean the same thing, "no value." Three states become one. That kills a pile of conditionals and a lot of arguing over what a payload meant. Sometimes the distinction is real, and you have to respect it. PATCH under RFC 7396 gives null a delete meaning, and GraphQL nullability and OpenAPI 3.1 union types drag you back into the same question. When a field genuinely needs it, document the exception and enforce it at the edges.

There are three ways to say "no meaningful value" in a JSON payload, and you see all three constantly. Drop the field:

{ "name": "Justin Time" }

Send an explicit null:

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

Or, for a string, send an empty one:

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

You see all three constantly. On every API I've shipped I've read them as the same thing, and I keep defending that as a rule. Omitted, null, and empty all mean "no value" until a field in your domain earns the right to disagree. Default to omission, and write the rule down so you can hold the line from the request body to the serializer.

Why fold them together

Point enough clients at an API and the three states drift. One client sends null, another omits the field, and a form library ships "" because someone cleared an input box. The server ends up with three code paths where one would do. Fold the states into one and the conditionals drop away on both sides, and the contract stops inviting arguments.

Payload size is a bonus. A field you leave out costs zero bytes and loses no meaning. The parser has less to read, and your cache compresses the leaner body better.

Where it breaks

Blunder into one of the exceptions and it hurts.

JSON Merge Patch is the big one. In a PATCH body under RFC 7396, an omitted field means "leave it alone" and a null means "delete the field." Want "clear versus leave alone"? Stand up a PATCH endpoint that speaks Merge Patch and spell out the semantics.

OpenAPI 3.1 lined up with JSON Schema, where null is a genuine type and absence is something else. Only model a null when you actually need tri-state behavior (see the OpenAPI 3.1 release notes, the specification, and JSON Schema's null type).

GraphQL makes every field nullable out of the gate and treats non-null (!) as a promise you have to make on purpose. Recent work on semantic nullability tightens how errors and nulls behave. Running a GraphQL backend? Decide your nullability deliberately instead of inheriting whatever the default hands you. Apollo has a solid nullability guide, and the GraphQL Conf 2024 talk on Semantic Nullability earns the half hour.

What I actually ship

By default I treat omitted, null, and empty string as one thing, "no value." Responses leave the field out instead of returning null. Requests get normalized at the door, with null and "" folded into omission, unless a field's contract spells out something different. OpenAPI 3.1 schemas stay on a single type with the property optional, and I reserve type: ["string","null"] for the fields that genuinely need a null. When a client has to clear something, it reaches for Merge Patch or an explicit command like clearHomePhone: true. The edges do the enforcing. Serializers strip empties going out, validators normalize coming in, and contract tests catch whoever drifts.

Node patterns

Input normalization middleware (Express/Fastify)

Normalize requests up front so the rest of the app only ever sees a value that's present or a field that's 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

Loosen the heuristic if your domain actually treats "" as real content.

Validation with Ajv (JSON Schema)

Use a plain JSON Schema with one type. Skip the null union unless you truly 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);

Since the middleware already drops null and "", a client can send omitted, null, or "" and your schema stays simple.

Validation with 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 some field really has to allow null, say so out loud:

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

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

For the default equivalence, keep the 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]

Where you truly need explicit tri-state, declare 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.

When "clear" and "leave alone" have to differ

Reach for HTTP PATCH with JSON Merge Patch semantics, and document that null removes a member while omission leaves it untouched.

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

{ "homePhone": null }

The server deletes homePhone.

No PATCH support? Expose an explicit command:

{ "clearHomePhone": true }

If you insist on keeping them distinct

Say you still want each spelling to mean something different across your API. Go ahead, but the bill runs long. You have to document what omitted, null, and empty mean for every field, write out the handling rules for requests and responses both, prove with real use cases that the distinction pays for itself, keep every service in step, and train every developer and client that touches the thing. Twice now I've watched a team get two items into that list before quietly walking it back.

References and further reading