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:
- Omitted
json
{ "name": "Justin Time" }
- Explicit
null
json
{ "name": "Justin Time", "homePhone": null }
- 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):
- Omitted → don’t change
null
→ delete 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 anull
when you truly need tri-state behavior. See OpenAPI 3.1 release notes, the specification, and JSON Schema’snull
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
- Default: Treat omitted,
null
, and empty string as the same “no value.” - Responses: Prefer omitting fields over returning
null
. - Requests: Normalize on ingress (coalesce
null
/""
to omitted) unless a field’s contract explicitly defines different meaning. - Schemas: In OpenAPI 3.1, avoid
type: ["string","null"]
unless you genuinely need anull
value. Use a single type and make the property optional by default. - Updates: When you need “clear,” use Merge Patch (
PATCH
) or an explicit command (e.g.,clearHomePhone: true
). - 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:
- Document per-field meaning (omitted vs.
null
vs. empty) - Specify handling rules (requests and responses)
- Prove real value with concrete use cases
- Guarantee consistency across every service
- Train every developer and client
If you can’t clear that bar, don’t make your future self maintain it.
References & Further Reading
-
RFC 7396 — JSON Merge Patch (IETF) https://www.rfc-editor.org/rfc/rfc7396
-
OpenAPI 3.1 — Release & Alignment with JSON Schema
- 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 —
null
is a real type (not “absent”) 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
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.