~/blog

Destructuring defaults ignore null from JSON

published

#javascript#json#api

TL;DR

A default in a destructuring pattern or a parameter list is used only when the value is undefined. null is a value, so const { pageSize = 20 } = { pageSize: null } leaves pageSize as null. JSON cannot carry undefined, so an “empty” field from an API arrives either as null or not at all, and only the second case gets your default. Read optional fields with ??, or turn null into a missing key at the point where the JSON enters your code.

The problem

A pagination helper with sensible defaults:

function paginate(items, { page = 1, pageSize = 20 } = {}) {
  const start = (page - 1) * pageSize;
  return items.slice(start, start + pageSize);
}

const items = Array.from({ length: 50 }, (_, i) => i + 1);

paginate(items).length;                          // 20
paginate(items, { pageSize: undefined }).length; // 20
paginate(items, { pageSize: null }).length;      // 0

The third call does not throw. pageSize is null, (1 - 1) * null is 0, 0 + null is 0, and items.slice(0, 0) is an empty array. The page renders “no results” and nothing in the logs says why.

The null rarely comes from your own code. It comes from a request body, a config file or another service:

const body = '{"page": 2, "pageSize": null}';
paginate(items, JSON.parse(body)); // []

When the whole options object is null, the = {} default on the parameter does not help either, for the same reason. On Node 26.2.0:

try {
  paginate(items, null);
} catch (err) {
  console.log(err.message); // Cannot read properties of null (reading 'page')
}

It is not specific to pagination. Any default that sits on a value which can be null lets the null through:

const greet = (name = 'there') => `Hello, ${name}`;
greet(null);                  // 'Hello, null'
new Date(null).toISOString(); // '1970-01-01T00:00:00.000Z'

Why it happens

The language substitutes a default only when the incoming value is undefined. MDN states it for destructuring: “The default value is used when the property is not present, or has value undefined. It is not used if the property has value null.” Default parameters follow the same rule: they apply “if no value or undefined is passed.”

Probing one default with every falsy value makes the rule visible:

const probe = ({ v = 'default' } = {}) => v;

probe({});               // 'default'
probe({ v: undefined }); // 'default'
probe({ v: null });      // null
probe({ v: 0 });         // 0
probe({ v: '' });        // ''
probe({ v: false });     // false

Compared with the two operators people reach for instead:

Incoming value{ v = d }v || dv ?? d
missing or undefinedddd
nullnulldd
0, '', false, NaNkeptdkept

The reason this keeps surprising people is JSON. JSON.stringify omits object properties whose value is undefined, so on the wire an empty optional field is spelled one of two ways, and which one you get depends on the server, not on you:

Server sideEmpty optional field becomesDestructuring default
Go encoding/json, pointer field set to nilnullskipped
Go, same field tagged omitemptykey left outapplies
Python json.dumps, value Nonenullskipped

Go’s documentation says it directly: “A nil pointer encodes as the null JSON value”, and omitempty drops a field whose value is “a nil pointer”. Python’s conversion table maps None to null. So the same client code works against one backend and quietly misbehaves against another, or starts misbehaving the day someone makes a column nullable.

TypeScript does not catch it by default either. JSON.parse is declared as returning any (lib.es5.d.ts, TypeScript 6.0.3), so a parsed null flows into a parameter typed { pageSize?: number } without a compiler error. The type says the field is a number or absent; the runtime value is neither.

What to do

Default with ?? instead of in the pattern. ?? treats null and undefined alike and leaves every other value alone:

function paginateSafe(items, options) {
  const page = options?.page ?? 1;
  const pageSize = options?.pageSize ?? 20;
  const start = (page - 1) * pageSize;
  return items.slice(start, start + pageSize);
}

paginateSafe(items, { pageSize: null }).length; // 20
paginateSafe(items, null).length;               // 20
paginateSafe(items, { pageSize: 0 }).length;    // 0

The last line still returns zero items, because the caller asked for zero. pageSize || 20 would have quietly turned that 0 into 20, which is the opposite bug.

Or drop nulls where the JSON comes in, so the rest of the code can keep its destructuring defaults:

function dropNulls(obj) {
  return Object.fromEntries(
    Object.entries(obj ?? {}).filter(([, value]) => value !== null),
  );
}

paginate(items, dropNulls(JSON.parse(body))); // [21, 22, …, 40]

For one field on an object you are already mutating, use ??=:

const opts = JSON.parse(body);
opts.pageSize ??= 20; // { page: 2, pageSize: 20 }

Caveats

References