The Null Equivalence Principle for JSON APIs
Jens Haaning, Take the Money and Run
Summary
Most APIs should treat an omitted field the same way they treat a null one, and an empty value the same as both. Collapsing the three states cuts complexity and ambiguity. Distinct semantics are sometimes real, though. PATCH under RFC 7396 gives null a delete meaning, and GraphQL nullability and OpenAPI 3.1 union types raise the same question. When you need one of those, document the exception and enforce it.
Three ways of saying "there is no meaningful value" show up in JSON payloads constantly. You can leave the field out entirely:
{ "name": "Justin Time" }
Sending an explicit null also works:
{ "name": "Justin Time", "homePhone": null }
Or, if the field is a string, you can send an empty one:
{ "name": "Justin Time", "homePhone": "" }
Same meaning, three spellings. In most APIs these are semantically equivalent, and I treat that as a rule worth defending: unless your domain proves otherwise, omitted, null, and empty are all the same "no value." Prefer omission, and write the rule down so you can enforce it end to end.
Why bother
Every API team I've worked with eventually hits the drift problem. Client A sends null while client B omits the field, and somewhere a form library is sending "" because a user cleared an input box. Now the server has three code paths where one would do. Collapsing the states means fewer conditionals on both ends and a contract with less room for argument.
There's a performance angle too. Omitted fields trim payload size with zero loss of meaning. Parsers get less to chew on, and caches compress the smaller payloads better.
Where the equivalence breaks
Stumbling into one of these by accident hurts.
JSON Merge Patch is the big one. In a PATCH body under RFC 7396, an omitted field means "don't change" and a null means "delete the field." Need "clear vs. leave alone"? Support a PATCH endpoint that speaks Merge Patch and document the semantics.
OpenAPI 3.1 aligned itself with JSON Schema, where null is a real type and absence is a different thing entirely. Only model a null when you truly need tri-state behavior (see the OpenAPI 3.1 release notes, the specification, and JSON Schema's null type).
GraphQL makes fields nullable by default and treats non-null (!) as an explicit promise. Newer work on semantic nullability sharpens error and null handling. If you run a GraphQL backend, design your nullability on purpose instead of inheriting it by accident. Apollo has a good nullability guide, and the GraphQL Conf 2024 talk on Semantic Nullability is worth your time.
The policy I ship
By default, treat omitted, null, and empty string as the same "no value." Responses omit the field rather than returning null. Requests get normalized at ingress, with null and "" coalesced into omission, unless a field's contract explicitly defines a different meaning. OpenAPI 3.1 schemas stay on a single type, with the property optional, and type: ["string","null"] reserved for fields that genuinely need a null value. When a client has to clear something, it gets Merge Patch or an explicit command like clearHomePhone: true. The edges enforce all of this. Serializers strip empties on the way out and validators normalize on the way in. Contract tests catch anyone who drifts.
Node patterns
Input normalization middleware (Express/Fastify)
Normalize requests so the rest of your app only ever sees a present value or an absent field.
// 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.
Validation with 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 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 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
});
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, 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]
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 you need "clear vs. leave alone"
Reach for HTTP PATCH with JSON Merge Patch semantics, and document that null removes a member while omission leaves it alone.
PATCH /people/123
Content-Type: application/merge-patch+json
{ "homePhone": null }
The server deletes homePhone.
If you can't support PATCH, expose an explicit command:
{ "clearHomePhone": true }
The recalcitrant's burden
Maybe you still want distinct semantics across your API. Fine, but the bill is long. You have to document what omitted, null, and empty each mean for every single field, spell out the handling rules for both requests and responses, prove with concrete use cases that the distinction earns its keep, keep every service consistent, and train every developer and client who touches the thing. I've watched teams get about two items into that list before quietly giving up.
References and further reading
-
RFC 7396, JSON Merge Patch (IETF): https://www.rfc-editor.org/rfc/rfc7396
-
OpenAPI 3.1 release and JSON Schema alignment
- OpenAPI Initiative release notes: https://www.openapis.org/blog/2021/02/18/openapi-specification-3-1-released
- "Nullable removed" preview: https://www.openapis.org/blog/2020/12/15/from-apidays-paris-openapi-3-1-coming-soon
-
The 3.1 specification: https://swagger.io/specification/
-
JSON Schema:
nullis a real type, and absent is a different thing https://json-schema.org/understanding-json-schema/reference/null -
GraphQL nullability
- Apollo guide: https://www.apollographql.com/docs/graphos/schema-design/guides/nullability
- GraphQL Conf 2024, Semantic Nullability: https://graphql.org/conf/2024/schedule/8daaf10ac70360a7fade149a54538bf9/
-
Learn nullability: https://graphql.com/learn/nullability/
-
Node tooling
- Ajv, getting started: https://ajv.js.org/guide/getting-started.html
- Zod,
.nullable()and.nullish(): https://v3.zod.dev/?id=nullable and https://v3.zod.dev/?id=nullish - JSON Merge Patch for Node: https://www.npmjs.com/package/json-merge-patch