Destructuring defaults ignore null from JSON
published
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 || d | v ?? d |
|---|---|---|---|
missing or undefined | d | d | d |
null | null | d | d |
0, '', false, NaN | kept | d | kept |
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 side | Empty optional field becomes | Destructuring default |
|---|---|---|
Go encoding/json, pointer field set to nil | null | skipped |
Go, same field tagged omitempty | key left out | applies |
Python json.dumps, value None | null | skipped |
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
dropNullsis shallow. Nested objects keep their nulls. If the payload is deep, recurse or validate it against a schema instead of patching it by hand.- Do not strip nulls where
nullcarries meaning. In JSON Merge Patch (RFC 7396), “Null values in the merge patch are given special meaning to indicate the removal of existing values in the target.” A PATCH handler that drops nulls turns “clear this field” into “leave it alone”. ??cannot be combined with&&or||without parentheses.a || b ?? cis aSyntaxError, so when you swap||for??in a longer expression, add the parentheses that say which one runs first.