JSON null Handling in JavaScript: undefined, Nullish Coalescing & TypeScript

Last updated:

JSON null is an explicit value — it serializes to the literal null token, while JavaScript undefined is not a valid JSON value and is stripped by JSON.stringify(). JSON.stringify(({a: undefined, b: null})) outputs {"b":null} — the a key disappears entirely; JSON.parse('{"a":null}') returns { a: null } with the key present and value null. This guide covers null vs undefined in JSON serialization, nullish coalescing for safe defaults, optional chaining for null-safe access, TypeScript nullable types, and API design patterns for representing absent vs unknown values.

null vs undefined: What JSON.stringify() Does with Each

The key behavioral difference: null is a valid JSON data type that serializes faithfully, while undefined is a JavaScript-only concept that JSON has no representation for. JSON.stringify() resolves this mismatch with three rules: undefined object values are dropped, undefined array elements become null, and a top-level undefined returns the JavaScript undefined value (not the string).

// ── Object properties ─────────────────────────────────────────────
const obj = {
  name: "Alice",
  age: undefined,       // stripped — key disappears
  score: null,          // kept — serializes to null
  active: false,        // kept — false is valid JSON
  count: 0,             // kept — 0 is valid JSON
  label: "",            // kept — "" is valid JSON
};

JSON.stringify(obj);
// '{"name":"Alice","score":null,"active":false,"count":0,"label":""}'
// Note: "age" key is GONE

// ── Verify round-trip behavior ────────────────────────────────────
const parsed = JSON.parse(JSON.stringify(obj));
console.log("age" in parsed);    // false — key never existed in JSON
console.log("score" in parsed);  // true
console.log(parsed.score);       // null

// ── Array elements ────────────────────────────────────────────────
const arr = [1, undefined, null, 3, undefined];

JSON.stringify(arr);
// '[1,null,null,3,null]'
// undefined elements → null (preserves array length / indices)

const parsedArr = JSON.parse(JSON.stringify(arr));
// [1, null, null, 3, null]
console.log(parsedArr[1]); // null — not undefined

// ── Top-level undefined ───────────────────────────────────────────
console.log(JSON.stringify(undefined));    // undefined (not a string!)
console.log(JSON.stringify(null));         // 'null' (the string)
console.log(typeof JSON.stringify(undefined)); // "undefined"

// ── Functions and Symbols — same as undefined ─────────────────────
JSON.stringify({ fn: () => {}, sym: Symbol("x"), val: 42 });
// '{"val":42}' — fn and sym dropped, just like undefined

// ── toJSON() controls null output ─────────────────────────────────
class SomeValue {
  constructor(public raw: string | null) {}
  toJSON() {
    // Return null explicitly to serialize as JSON null
    return this.raw ?? null;
  }
}

JSON.stringify({ field: new SomeValue(null) });
// '{"field":null}'

JSON.stringify({ field: new SomeValue("hello") });
// '{"field":"hello"}'

// ── replacer function: control null/undefined output ─────────────
const replacer = (key: string, value: unknown) => {
  if (value === undefined) return null;   // convert undefined → null
  return value;
};

JSON.stringify({ a: undefined, b: null }, replacer);
// '{"a":null,"b":null}' — now "a" is kept as null

The replacer function is the escape hatch when you need to preserve undefined-valued keys in JSON output — return null explicitly instead of letting JSON.stringify() strip them. This is useful when serializing to a wire format where a missing key and a null key have different semantics for the receiver. See our guide on JSON.parse() for the full parse/stringify API. Note that JSON.stringify() also silently drops Symbol-keyed properties (even without undefined involvement) — Symbol keys are never enumerable in JSON serialization.

Nullish Coalescing (??) and Optional Chaining (?.) for Safe JSON Access

Nullish coalescing (??) and optional chaining (?.) are the two ES2020 operators purpose-built for safe JSON access. Optional chaining short-circuits at the first null or undefined in a property chain instead of throwing a TypeError. Nullish coalescing provides a fallback that activates only for null/undefined — crucially, it does not activate for 0, "", or false, which are valid JSON values you should not accidentally replace with a default.

// ── ?? vs || — the critical difference ──────────────────────────
const data = { count: 0, label: "", active: false, score: null };

// ?? — only triggers on null/undefined
const count  = data.count  ?? 10;   // 0   ← correct, preserves 0
const label  = data.label  ?? "N/A"; // ""  ← correct, preserves ""
const active = data.active ?? true;  // false ← correct
const score  = data.score  ?? -1;   // -1  ← triggers, null → default
const missing = data.missing ?? "default"; // "default" ← key absent

// || — triggers on ANY falsy value (dangerous for JSON)
const countBad  = data.count  || 10;   // 10  ← WRONG, replaces valid 0
const labelBad  = data.label  || "N/A"; // "N/A" ← WRONG, replaces valid ""
const activeBad = data.active || true;  // true ← WRONG, ignores false

// ── Optional chaining ?. ─────────────────────────────────────────
const response = {
  user: {
    profile: {
      address: null,   // explicitly null from API
    },
  },
};

// Without ?. — throws TypeError if address is null
// const city = response.user.profile.address.city; // TypeError!

// With ?. — returns undefined instead of throwing
const city = response?.user?.profile?.address?.city;
// undefined — no error thrown

// ?. with ?? for a default
const displayCity = response?.user?.profile?.address?.city ?? "Unknown";
// "Unknown"

// ?. works with methods and bracket notation too
const first = response?.user?.profile?.tags?.[0] ?? null;
const len   = response?.user?.profile?.bio?.length ?? 0;

// ── Nullish assignment (??=) — set only if null/undefined ─────────
let config: { timeout?: number | null; retries?: number } = {};

config.timeout ??= 5000;   // set because undefined
config.retries ??= 3;      // set because undefined
config.timeout ??= 9999;   // NOT set — already 5000

console.log(config); // { timeout: 5000, retries: 3 }

// ── Optional chaining with JSON.parse result ──────────────────────
function safeGet<T>(json: string, path: string[]): T | undefined {
  try {
    let obj: unknown = JSON.parse(json);
    for (const key of path) {
      if (obj === null || typeof obj !== "object") return undefined;
      obj = (obj as Record<string, unknown>)[key];
    }
    return obj as T;
  } catch {
    return undefined;
  }
}

const raw = '{"user":{"address":{"city":"NYC"}}}';
const city2 = safeGet<string>(raw, ["user", "address", "city"]) ?? "Unknown";
// "NYC"

const missing2 = safeGet<string>(raw, ["user", "phone", "number"]) ?? "N/A";
// "N/A"

The ??= nullish assignment operator is the cleanest way to initialize object properties from parsed JSON when you only want to fill in missing or null values without touching keys that already hold valid falsy values like 0 or false. Avoid chaining ?. unnecessarily deep — if an intermediate null is actually an error (the server should always return a user object, for instance), let the TypeError surface rather than silently returning undefined. Optional chaining is for genuinely optional data; it should not be used to suppress bugs.

TypeScript Nullable Types for JSON Fields

TypeScript has three distinct nullable patterns for JSON fields, each with different semantics. Understanding which pattern matches the API contract prevents runtime surprises. T | null means the key is always present but may carry no value. T | undefined means the key technically could be absent from the parsed object. Optional property (key?: T) means the key may not exist at all. For JSON API responses, the choice between these patterns should mirror the server contract.

// ── Three nullable patterns ──────────────────────────────────────
interface User {
  id: number;
  name: string;
  middleName: string | null;    // always present, may be null
  nickname?: string;            // may be absent entirely (T | undefined)
  deletedAt: string | null;     // present, null means not deleted
  metadata?: Record<string, unknown> | null; // absent or null or object
}

// Parse JSON and narrow types
const raw = '{"id":1,"name":"Alice","middleName":null,"deletedAt":null}';
const user: User = JSON.parse(raw) as User;

// TypeScript forces you to handle null before using the value
// user.middleName.toUpperCase(); // Error: Object is possibly null
const upper = user.middleName?.toUpperCase() ?? "(no middle name)";

// ── Zod schema matching the three patterns ────────────────────────
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  middleName: z.string().nullable(),          // string | null
  nickname: z.string().optional(),            // string | undefined
  deletedAt: z.string().nullable(),           // string | null
  metadata: z.record(z.unknown()).nullish(),  // string | null | undefined
});

type User = z.infer<typeof UserSchema>;

// Parse and validate in one step
const parsed = UserSchema.parse(JSON.parse(raw));
// TypeScript now knows the exact nullable shape

// ── Narrowing null in TypeScript ──────────────────────────────────
function processUser(user: User) {
  // Narrowing with if check
  if (user.middleName !== null) {
    // TypeScript narrows: middleName is string here
    console.log(user.middleName.toUpperCase());
  }

  // Narrowing with ?? (keeps type as string)
  const displayMiddle = user.middleName ?? "(none)";
  // displayMiddle: string — null case handled

  // Type predicate for array filtering
  function isPresent<T>(val: T | null | undefined): val is T {
    return val !== null && val !== undefined;
  }

  const users: Array<User | null> = [user, null, user];
  const activeUsers: User[] = users.filter(isPresent);
  // TypeScript knows activeUsers is User[], not (User | null)[]
}

// ── Utility types for nullable JSON ──────────────────────────────
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Nullish<T> = T | null | undefined;

// DeepNullable — makes all properties nullable recursively
type DeepNullable<T> = {
  [K in keyof T]: T[K] extends object ? DeepNullable<T[K]> | null : T[K] | null;
};

// NonNullableFields — strips null from all fields
type NonNullableFields<T> = {
  [K in keyof T]: NonNullable<T[K]>;
};

// ── JSON.parse with type assertion vs Zod ────────────────────────
// Unsafe — TypeScript trusts the assertion blindly
const unsafeUser = JSON.parse(raw) as User;
// unsafeUser.middleName could be anything at runtime

// Safe — Zod validates at runtime
const safeUser = UserSchema.safeParse(JSON.parse(raw));
if (safeUser.success) {
  // safeUser.data is fully typed and validated
  console.log(safeUser.data.middleName); // string | null — guaranteed
}

Prefer z.string().nullable() over z.string().nullish() when the JSON API contract guarantees the key will always be present — nullish() permits the key to be absent entirely, which hides server bugs where required fields go missing. Use z.string().nullish() only for fields that are genuinely optional AND may be explicitly null. See our guide on TypeScript JSON types for a broader treatment of typing parsed JSON responses.

Filtering Out Null Values from JSON Arrays and Objects

Filtering nulls is a common post-parse operation when working with JSON API responses that include null placeholders for optional fields. JavaScript provides several approaches depending on whether you need shallow filtering (top-level only), deep filtering (recursive), or typed filtering (TypeScript with narrowed return types).

// ── Filter null from array (shallow) ─────────────────────────────
const arr = [1, null, 2, null, 3];
const clean = arr.filter(x => x !== null);
// [1, 2, 3]

// Filter null AND undefined
const withUndefined = [1, null, undefined, 2];
const cleaned = withUndefined.filter(x => x != null);  // loose !=
// [1, 2]

// TypeScript typed predicate — narrows return type
function isNotNull<T>(val: T | null): val is T {
  return val !== null;
}

const items: Array<string | null> = ["a", null, "b", null, "c"];
const strings: string[] = items.filter(isNotNull);
// TypeScript knows strings is string[], not (string | null)[]

// Combined null + undefined predicate
function isDefined<T>(val: T | null | undefined): val is T {
  return val !== null && val !== undefined;
}

// ── Filter null values from object (shallow) ──────────────────────
const obj = { name: "Alice", age: null, score: 0, active: null };

const withoutNulls = Object.fromEntries(
  Object.entries(obj).filter(([, v]) => v !== null)
);
// { name: "Alice", score: 0 }
// Note: score: 0 is preserved — 0 !== null

// Filter null AND undefined from object
const withoutNullish = Object.fromEntries(
  Object.entries(obj).filter(([, v]) => v != null)
);

// TypeScript typed version
type WithoutNull<T> = { [K in keyof T]: NonNullable<T[K]> };

function stripNulls<T extends Record<string, unknown>>(obj: T) {
  return Object.fromEntries(
    Object.entries(obj).filter(([, v]) => v !== null)
  ) as Partial<WithoutNull<T>>;
}

// ── Deep null filter — recursive ──────────────────────────────────
function deepStripNulls(value: unknown): unknown {
  if (value === null) return undefined;   // null → omit
  if (Array.isArray(value)) {
    return value
      .map(deepStripNulls)
      .filter(v => v !== undefined);       // remove null array elements
  }
  if (typeof value === "object" && value !== null) {
    const result: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(value)) {
      const stripped = deepStripNulls(v);
      if (stripped !== undefined) {        // omit null-origin keys
        result[k] = stripped;
      }
    }
    return result;
  }
  return value;  // primitive — pass through
}

const apiResponse = {
  user: { name: "Alice", middleName: null },
  tags: ["admin", null, "editor"],
  meta: null,
};

console.log(deepStripNulls(apiResponse));
// { user: { name: "Alice" }, tags: ["admin", "editor"] }
// Note: middleName key dropped, null tag removed, meta key dropped

// ── Replace null with default values (not filter) ─────────────────
const withDefaults = Object.fromEntries(
  Object.entries(obj).map(([k, v]) => [k, v ?? "N/A"])
);
// { name: "Alice", age: "N/A", score: 0, active: "N/A" }
// score: 0 preserved — ?? only triggers on null/undefined

The deepStripNulls function converts null to undefined internally, then uses the undefined signal to omit keys and array elements. This approach avoids mutating the original object. When filtering arrays, be careful about index semantics — removing nulls from an array shifts all subsequent indices, which can break code that accesses array elements by position. If position matters, replace nulls with a sentinel value rather than filtering them out.

JSON Schema: Allowing null with Type Arrays

JSON Schema defines how to allow null values for a field using the type keyword as an array. The approach differs slightly between JSON Schema draft versions and OpenAPI, which has its own nullable conventions. Getting this right is essential for generated client SDKs, form validators, and API documentation that correctly reflect nullable fields. See our JSON Schema guide for the full specification overview.

// ── JSON Schema Draft 4-7 and Draft 2019-09+ ─────────────────────
// Allow string OR null for a field
{
  "type": "object",
  "properties": {
    "middleName": {
      "type": ["string", "null"],
      "description": "User's middle name, or null if not provided"
    },
    "age": {
      "type": ["integer", "null"],
      "minimum": 0
    },
    "tags": {
      "type": ["array", "null"],
      "items": { "type": "string" }
    }
  },
  "required": ["middleName", "age"]
}

// Valid instances:
// { "middleName": "Jane", "age": 30 }       — string and integer
// { "middleName": null, "age": null }         — both null
// { "middleName": null, "age": 30 }           — one null, one integer

// Invalid instances:
// { "middleName": 42, "age": 30 }             — wrong type for middleName
// { "age": 30 }                               — middleName required (even if null)

// ── Optional field that may be null if present ────────────────────
// NOT in required array → may be absent
// type array includes null → if present, may be null
{
  "type": "object",
  "properties": {
    "nickname": { "type": ["string", "null"] }
  }
  // nickname not in required — it may be absent OR null OR a string
}

// ── Nullable with constraints ─────────────────────────────────────
{
  "properties": {
    "score": {
      "oneOf": [
        { "type": "number", "minimum": 0, "maximum": 100 },
        { "type": "null" }
      ]
    }
  }
}
// oneOf approach: score is a number in [0,100] OR null
// type array approach cannot carry constraints like minimum
// Use oneOf when you need type-specific validation alongside null

// ── OpenAPI 3.0 (nullable: true extension) ───────────────────────
// OpenAPI 3.0 does NOT follow JSON Schema — uses nullable: true
{
  "type": "object",
  "properties": {
    "middleName": {
      "type": "string",
      "nullable": true
    }
  }
}

// ── OpenAPI 3.1 (aligned with JSON Schema 2020-12) ───────────────
// OpenAPI 3.1 drops nullable: true — uses type array like JSON Schema
{
  "type": "object",
  "properties": {
    "middleName": {
      "type": ["string", "null"]
    }
  }
}

// ── AJV validation — compile once, validate many times ───────────
import Ajv from "ajv";

const ajv = new Ajv();
const schema = {
  type: "object",
  properties: {
    name: { type: "string" },
    age: { type: ["integer", "null"] },
  },
  required: ["name", "age"],
};

const validate = ajv.compile(schema);

console.log(validate({ name: "Alice", age: null }));  // true
console.log(validate({ name: "Alice", age: 30 }));    // true
console.log(validate({ name: "Alice" }));             // false — age required

A common mistake is using "type": "null" (singular) as the only type for a field that should accept both a real value and null — this makes the field always null, which is probably not the intent. The type array form (["string", "null"]) is the correct approach. When using oneOf for nullable with constraints, ensure the subschemas are truly mutually exclusive — a numeric value of 0 could technically satisfy {"type": "null"} in some edge cases if the schema is malformed. AJV validates the type array form correctly out of the box without additional plugins.

API Design: null vs Missing Key vs Empty String

A well-designed JSON API uses null, absent keys, and empty strings to communicate three distinct states. Conflating these states leads to ambiguous contracts where consumers cannot reliably distinguish "server says no value" from "server did not include this field" from "value is an empty string." Establishing consistent conventions up front prevents breaking changes later.

// ── Three states and their semantic meaning ───────────────────────
// 1. null  — field exists, value is explicitly absent
// 2. key missing — field not included in response (unknown or N/A)
// 3. ""    — field exists with an empty string value (valid but empty)

// Example: user profile endpoint

// User with complete profile
{
  "id": 1,
  "name": "Alice",
  "middleName": null,         // known to have no middle name
  "bio": "Developer",         // non-empty string
  "phone": "+1234567890"
}

// User with incomplete profile (profile never filled out)
{
  "id": 2,
  "name": "Bob"
  // middleName absent — server doesn't know
  // bio absent — never asked
  // phone absent — unknown
}

// User who cleared their bio
{
  "id": 3,
  "name": "Carol",
  "bio": null                 // explicitly set to null (cleared)
}

// ── PATCH semantics: null means clear, absent means leave ─────────
// JSON Merge Patch (RFC 7396) formalizes this convention
// PATCH /users/1
{
  "bio": null          // → clear bio (set to null in DB)
}
// Result: { "id": 1, "bio": null }

// PATCH /users/1
{
  // bio absent → do not touch bio
}
// Result: bio unchanged

// PATCH /users/1
{
  "bio": ""            // → set bio to empty string (valid value)
}
// Result: { "id": 1, "bio": "" }

// ── Express endpoint handling PATCH correctly ─────────────────────
import express from "express";
const app = express();
app.use(express.json());

app.patch("/users/:id", async (req, res) => {
  const updates: Record<string, unknown> = {};

  for (const [key, value] of Object.entries(req.body)) {
    // null → explicit clear; string/number → update; absent → skip
    if (value === null || typeof value === "string" || typeof value === "number") {
      updates[key] = value;  // include null explicitly
    }
    // undefined cannot appear in parsed JSON body — JSON.parse drops it
  }

  // updates only contains keys explicitly included in PATCH body
  await db.update("users", req.params.id, updates);
  res.json({ updated: Object.keys(updates) });
});

// ── API response design checklist ────────────────────────────────
// ✓ Use null for fields that are known to have no value
// ✓ Omit fields that are not applicable or unknown in this context
// ✓ Never use "" to represent "no value" — use null
// ✓ Document each field: can it be null? can it be absent? or both?
// ✓ Be consistent: if middleName is nullable, always include it (as null)
//   rather than sometimes omitting it and sometimes nulling it
// ✓ For PATCH: null means clear, absent means unchanged (RFC 7396)

// ── Distinguishing null vs missing in consumer code ───────────────
interface UserResponse {
  id: number;
  name: string;
  middleName: string | null;  // always present (required in schema)
  bio?: string | null;        // may be absent or null
}

function handleUser(user: UserResponse) {
  // Distinguish all three bio states
  if (!("bio" in user)) {
    console.log("Bio: not yet fetched");
  } else if (user.bio === null) {
    console.log("Bio: explicitly cleared");
  } else if (user.bio === "") {
    console.log("Bio: set to empty string");
  } else {
    console.log("Bio:", user.bio);
  }
}

The "bio" in user check is the only reliable way to distinguish an absent key from a key explicitly set to undefined in JavaScript. In a parsed JSON object, undefined property values never appear (they would have been stripped by JSON.stringify() on the server side), so in checks are reliable: a missing key means the server did not include it. Establish a team convention for each nullable field early — changing from "always present, nullable" to "sometimes absent" is a breaking API change that requires a version bump.

Database NULL vs JSON null: PostgreSQL and MongoDB Behavior

Database-layer null handling adds another dimension to JSON null semantics. PostgreSQL distinguishes SQL-level NULL (the row or column has no value in the relational sense) from a JSON value of null stored inside a JSON or JSONB column (a JSON null token inside the document). MongoDB has similar distinctions. Understanding these layers prevents confusion when querying JSON columns for null values.

-- ── PostgreSQL: SQL NULL vs JSON null ───────────────────────────

CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  data JSONB               -- the column itself can be SQL NULL or a JSONB value
);

-- Insert SQL NULL (the column has no value at all)
INSERT INTO events (data) VALUES (NULL);

-- Insert a JSONB object with a JSON null field
INSERT INTO events (data) VALUES ('{"user": null, "score": 42}');

-- Insert a JSONB null (the column value IS JSON null)
INSERT INTO events (data) VALUES ('null');

-- ── Querying: SQL NULL vs JSON null ──────────────────────────────

-- Find rows where the column itself is SQL NULL
SELECT * FROM events WHERE data IS NULL;
-- Matches row 1 (column has no value)

-- Find rows where the JSONB column is JSON null token
SELECT * FROM events WHERE data = 'null'::jsonb;
-- Matches row 3 (column value is JSON null)

-- Find rows where a field inside the JSON is null
SELECT * FROM events WHERE data->>'user' IS NULL;
-- Matches rows 1 (SQL NULL column), 3 (JSON null column), AND row 2 (user field is JSON null)
-- ->> returns text; NULL JSON field returns SQL NULL text

-- Find rows where the "user" key exists AND is JSON null
SELECT * FROM events WHERE
  data ? 'user'                        -- key exists
  AND (data->'user') = 'null'::jsonb;  -- value is JSON null
-- Only matches row 2

-- ── PostgreSQL functions for null handling ────────────────────────

-- COALESCE: handles SQL NULL
SELECT COALESCE(data->>'score', '0') FROM events;
-- Returns '0' for rows where data is SQL NULL or score key is absent

-- jsonb_strip_nulls: remove JSON null fields from JSONB
SELECT jsonb_strip_nulls('{"a": 1, "b": null, "c": null}'::jsonb);
-- Returns: {"a": 1}

-- Check if a field exists and is not null
SELECT * FROM events
WHERE data IS NOT NULL              -- column is not SQL NULL
  AND data ? 'user'                 -- 'user' key exists
  AND data->'user' != 'null'::jsonb; -- value is not JSON null

-- ── MongoDB: null vs missing field ───────────────────────────────
// In MongoDB, null queries match BOTH null values AND missing fields

// Find documents where age is null OR age field doesn't exist
db.users.find({ age: null });
// Matches: { name: "Alice", age: null }   — explicit null
// Matches: { name: "Bob" }               — field absent
// Does NOT match: { name: "Carol", age: 30 }

// Find ONLY documents where age field is explicitly null
db.users.find({ age: { $type: "null" } });
// Only matches: { name: "Alice", age: null }

// Find ONLY documents where age field is missing
db.users.find({ age: { $exists: false } });
// Only matches: { name: "Bob" }

// Find documents where age exists AND is not null
db.users.find({ age: { $ne: null, $exists: true } });

The PostgreSQL jsonb_strip_nulls() function is useful for cleaning up stored JSONB documents that have accumulated null fields over time — it removes all JSON null values recursively, making the document smaller. When using ->>'field' (text extraction operator) in PostgreSQL, the result is SQL NULL for both absent keys and JSON null values, which means you cannot distinguish them with text extraction alone — use the ->'field' = 'null'::jsonb comparison on the JSONB extraction operator to check for JSON null specifically. In MongoDB, the implicit null-matches-missing behavior in queries catches many developers off guard — always use $type: "null" or $exists when you need to distinguish the two states precisely.

Key Terms

null (JSON)
A first-class value type in the JSON specification, serialized as the literal four-character token null. JSON null is one of the six JSON value types (null, boolean, number, string, array, object). It represents the intentional absence of a value — the key is present in the JSON document and explicitly carries no value. JSON.parse('{"a":null}') produces the JavaScript object { a: null } where a is present and its value is the JavaScript null primitive. JSON null round-trips perfectly through parse and stringify without any data loss, unlike JavaScript undefined.
undefined (JavaScript)
A JavaScript primitive type that has no representation in the JSON specification. JSON.stringify() handles undefined by dropping it from object properties silently, converting array elements to null, and returning the JavaScript undefined value (not a string) when passed as the top-level argument. Function values and Symbols receive the same treatment. The only way to preserve the presence of a "no value" key in JSON is to use null instead of undefined, or to use a replacer function that converts undefined to null explicitly.
nullish coalescing
The ?? operator introduced in ES2020 that returns the right-hand operand when the left-hand operand is null or undefined, and returns the left-hand operand for all other values including 0, "", false, and NaN. This makes it safer than the logical OR || operator for providing default values for JSON fields, because || also treats valid falsy values (0, "", false) as needing a default. The nullish assignment operator ??= sets a property only if it is currently null or undefined, making it useful for initializing optional JSON fields with defaults.
optional chaining
The ?. operator introduced in ES2020 that short-circuits a property access chain and returns undefined instead of throwing a TypeError when it encounters null or undefined in the chain. a?.b?.c is equivalent to a == null ? undefined : (a.b == null ? undefined : a.b.c). Works with dot notation (a?.b), bracket notation (a?.[key]), and method calls (a?.method()). Most useful when traversing parsed JSON API responses where intermediate objects may be null or absent. Should not be overused — if an intermediate null is a programming error, let the TypeError surface rather than hiding it with ?..
nullable type
A TypeScript type that includes null as a valid value: string | null, number | null. TypeScript enforces that nullable values must be narrowed (via !== null check, optional chaining, or nullish coalescing) before accessing their non-null properties or methods. Distinct from optional properties (key?: T, which is T | undefined) — nullable types require the key to be present in the object but allow its value to be null. In Zod, expressed as z.string().nullable(). When strictNullChecks is enabled in tsconfig.json (the default in strict mode), TypeScript distinguishes null, undefined, and T as separate types.
JSON Schema nullable
The JSON Schema mechanism for allowing null as a valid value for a field. In JSON Schema Draft 4 through 2019-09+, use "type": ["string", "null"] — an array of type names. In OpenAPI 3.0 (which does not fully follow JSON Schema), use "nullable": true alongside the base type. In OpenAPI 3.1 (which aligns with JSON Schema 2020-12), use the type array form. When constraints like minimum or pattern are needed alongside null, use oneOf: [{"type": "number", "minimum": 0}, {"type": "null"}]. A field that is nullable and optional requires both the type array and omission from the required array.
absent value
A JSON key that is not present in a JSON object at all — distinct from a key whose value is null. In JavaScript, accessing an absent key returns undefined; the in operator returns false; Object.hasOwn(obj, key) returns false. In JSON APIs, an absent field conventionally means the server did not include the field in this response — it may be unknown, not applicable, or omitted for bandwidth reasons. Distinguished from null (server knows the field exists and has no value) and empty string (valid value that happens to be empty). JSON Merge Patch (RFC 7396) formalizes the distinction: null in a patch document removes a field, while an absent key leaves the field unchanged.

FAQ

What is the difference between null and undefined in JSON?

JSON null is a valid JSON value defined in the JSON specification (RFC 8259) — it serializes to the literal token null and round-trips through JSON.stringify() and JSON.parse() without loss. JavaScript undefined has no representation in JSON at all. When JSON.stringify() encounters undefined as an object property value, it silently drops the key — the key disappears from the output entirely. When it encounters undefined as an array element, it converts to null to preserve array length. JSON.parse('{"a":null}') returns an object with a present and equal to null; there is no way to produce an object with an undefined-valued key from JSON.parse(). This means after a round-trip through JSON, a property set to undefined becomes either absent (if it was an object value) or null (if it was an array element). For API contracts, use null to explicitly communicate "no value" — never rely on undefined surviving serialization. The distinction is semantically important: null means "known to be absent," while a missing key means "not included in this response."

How does JSON.stringify() handle undefined values?

JSON.stringify() has three distinct behaviors for undefined depending on context. For object property values: the key-value pair is silently omitted — JSON.stringify(({ a: undefined, b: null })) produces {"b":null}, dropping a entirely. For array elements: undefined is replaced with null to preserve array indices — JSON.stringify([1, undefined, 3]) produces [1,null,3]. For a top-level value: JSON.stringify(undefined) returns the JavaScript undefined value (not a string), which means it cannot be assigned to a JSON string variable without a null check. Function values and Symbol values receive identical treatment to undefined — they are dropped from objects and replaced with null in arrays. To retain a key with a "no value" signal, use null instead of undefined, or pass a replacer function: JSON.stringify(obj, (k, v) => v === undefined ? null : v) converts all undefined values to null, keeping their keys in the output.

How do I safely access a JSON property that might be null?

Use optional chaining (?.) to traverse a chain of properties without throwing a TypeError when any step is null or undefined. Instead of response.user.address.city (throws if address is null), write response?.user?.address?.city — if any intermediate value is null or undefined, the expression short-circuits and returns undefined. Combine with nullish coalescing for a default: const city = response?.user?.address?.city ?? "Unknown". Optional chaining also works with bracket notation (arr?.[0]) and method calls (obj?.method()). The key advantage of ?? over || is that ?? does not replace valid falsy JSON values like 0, "", or false with the default — it only activates for null and undefined. For TypeScript, optional chaining and nullish coalescing together provide type narrowing: after const city = x?.y?.z ?? "", TypeScript knows city is a string, not string | undefined.

How do I filter null values from a JSON array in JavaScript?

Use Array.prototype.filter() to remove nulls from a JSON array. Strict null check (removes only null): const clean = arr.filter(x => x !== null). Loose check (removes null and undefined): const clean = arr.filter(x => x != null) — note the loose != operator. For TypeScript with correct return type narrowing, use a type predicate: function isNotNull<T>(val: T | null): val is T { return val !== null; } — then const strings: string[] = arr.filter(isNotNull), and TypeScript knows the result is string[] not (string | null)[]. For filtering nulls from object values: const clean = Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== null)). For deep recursive null filtering, write a recursive function that traverses nested objects and arrays, skipping null values and null-valued keys. Use flatMap for combined filter-and-transform: arr.flatMap(x => x !== null ? [transform(x)] : []) removes nulls and transforms in one pass.

How do I type a nullable JSON field in TypeScript?

TypeScript provides three nullable patterns for JSON fields, each with distinct semantics. T | null — the key is required and always present, but its value may be null: { middleName: string | null }. TypeScript requires handling the null case before accessing string methods. T | undefined — similar to optional but the key technically exists: rarely used directly for JSON. Optional property key?: T (shorthand for T | undefined) — the key may be entirely absent from the parsed object: { nickname?: string }. For JSON APIs, prefer string | null for fields the server always includes (even when null), and string? for fields the server may omit. In Zod: z.string().nullable() produces string | null; z.string().optional() produces string | undefined; z.string().nullish() produces string | null | undefined. Enable "strictNullChecks": true in tsconfig.json (included in "strict": true) to make TypeScript distinguish these types — without it, null and undefined are assignable to every type.

What is nullish coalescing and how does it differ from logical OR?

Nullish coalescing (??, ES2020) returns the right operand only when the left operand is null or undefined. Logical OR (||) returns the right operand for any falsy value: null, undefined, 0, "", false, NaN. For JSON data, ?? is almost always correct for defaults because JSON fields can legitimately carry falsy values. Comparison: 0 ?? 10 returns 0 (correct); 0 || 10 returns 10 (wrong — replaces valid zero). "" ?? "N/A" returns "" (correct); "" || "N/A" returns "N/A" (wrong — replaces valid empty string). false ?? true returns false (correct); false || true returns true (wrong). null ?? "default" and null || "default" both return "default" — they agree on null. Use || only when you genuinely want to treat 0, "", and false as absent values — common in legacy code before ?? existed, but avoid in new JSON-handling code. The nullish assignment ??= extends this: obj.count ??= 0 sets count to 0 only if it is currently null or undefined.

How do I allow null in a JSON Schema definition?

In JSON Schema Draft 4 through 2020-12, allow null by passing an array to the type keyword: {"type": ["string", "null"]}. This allows the value to be either a string or null. The field is still required (key must be present) unless you also omit it from the required array. To allow null alongside constraints (like minimum or pattern which only apply to non-null values), use oneOf: [{"type": "number", "minimum": 0}, {"type": "null"}]. In OpenAPI 3.0 (which diverges from JSON Schema), use "nullable": true alongside the base type. In OpenAPI 3.1 (aligned with JSON Schema 2020-12), use the type array form instead of nullable. AJV compiles the type array form correctly: ajv.compile({"type": ["string", "null"]}) validates both strings and null. For a field that is both optional (may be absent) and nullable (may be null when present): define the type as ["string", "null"] and omit the field name from the required array — the field may then be absent, null, or a string. See our JSON Schema guide for the full type system.

What is the difference between a missing key and a null value in JSON APIs?

A missing key and a null value represent semantically different states in a JSON API, even though both result in "no usable data" for the consumer. A null value means the server explicitly communicated "this field has no value" — the server knows about the field and confirmed it is empty. A missing key means the server did not include the field at all — it may be unknown, not applicable in this response context, or intentionally omitted. In practice: {"middleName": null} says "user has no middle name (we checked)"; {"name": "Alice"} (middleName absent) says "we don't know whether the user has a middle name in this response." For PATCH requests, the distinction is formalized by JSON Merge Patch (RFC 7396): sending null for a field instructs the server to clear it; an absent field instructs the server to leave it unchanged. JavaScript detection: "key" in obj returns false for missing keys and true for null-valued keys; obj.key === null detects explicit null. Design APIs with explicit documentation: specify for each field whether it is always present (possibly null), sometimes omitted, or both — and keep this consistent across all response paths.

Further reading and primary sources

  • MDN: nullMDN reference for the null literal in JavaScript, including comparison with undefined and type coercion behavior
  • MDN: Nullish coalescing operator (??)Full reference for the ?? operator, including short-circuit evaluation, operator precedence, and comparison with ||
  • MDN: Optional chaining (?.)MDN reference for ?. with dot notation, bracket notation, and method call forms, including short-circuit behavior
  • JSON Merge Patch (RFC 7396)RFC 7396 defining JSON Merge Patch — formalizes null means clear, absent means unchanged for PATCH operations
  • TypeScript Handbook: StrictnessTypeScript documentation on strictNullChecks and why enabling strict mode is essential for correct null handling