bastianplsfix

Nothing, twice

JavaScript has two values that mean "nothing", and they mean different nothings. undefined is what the language hands you when nothing was supplied. null only ever appears because somebody put it there. That is the whole distinction worth carrying: undefined is the language's absence, and null is a programmer's absence. Every behavior on this page follows from which of the two you are holding.

Create programs/nothing-twice.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

undefined arrives when nothing was supplied

You never have to create undefined. Five ordinary situations produce it on their own, and the first step collects all five. One note before you type it: with an exact object type, TypeScript rejects reading a property it never promised existed, so record is annotated as Record<string, string>, which tells the checker that arbitrary string keys are allowed. The annotation changes what TypeScript permits; it does not create any property at runtime.

Deno.test("undefined arrives when nothing was supplied", () => {
let uninitialized;
assertEquals(uninitialized, undefined);

const record: Record<string, string> = { note: "hi" };
assertEquals(record.missing, undefined);

const twoItems = [1, 2];
assertEquals(twoItems[5], undefined);

function returnsNothing() {}
assertEquals(returnsNothing(), undefined);

function takesMaybe(value?: number) {
return value;
}
assertEquals(takesMaybe(), undefined);
});
Check programs/nothing-twice.test.ts
running 1 test from ./programs/nothing-twice.test.ts
undefined arrives when nothing was supplied ... ok (0ms)

ok | 1 passed | 0 failed (2ms)

All five assertions pass, and each one is a different door into the same room.

  1. uninitialized was declared and never assigned, so reading it produces undefined: the language filling a gap you left open.
  2. record.missing asks the object for a key it does not have, so the read produces undefined instead of refusing to answer.
  3. twoItems[5] asks a two-element array for its sixth element, and an index past the end produces undefined the same way an absent key does.
  4. returnsNothing() has no return statement, so the call still produces a value, and that value is undefined.
  5. takesMaybe() was called without its argument, so inside the function the parameter value holds undefined.

Five doors, one room. In every case something was never supplied, and undefined is the value the language reaches for to say so. You did not write undefined anywhere in this step, and it appeared five times.

null appears only because somebody put it there

null shows up in none of those five situations. It is a value you assign yourself, or that an API assigns on your behalf to say "I looked, and there is deliberately nothing here." A few built-ins still do:

Deno.test("null appears only because somebody put it there", () => {
const chosen = null;
assertEquals(chosen, null);
assertEquals("oak".match(/z/), null);
assertEquals(JSON.parse("null"), null);
assertEquals(Object.getPrototypeOf(Object.prototype), null);
});
null appears only because somebody put it there ... ok (0ms)

Take the four lines in turn.

  1. chosen holds null because the code assigned it, which is the ordinary way null enters a program: on purpose.
  2. "oak".match(/z/) searched and found no match. That is not a gap; it is a complete answer, "no match", and the method spells that answer null.
  3. JSON.parse("null") read the four characters n, u, l, l and produced the value they name. Nothing was missing; the text said null.
  4. Object.getPrototypeOf(Object.prototype) walked to the top of the prototype chain and found its end, and the end of the chain is marked with null.

Each one is a deliberate answer, decided by someone. That is the difference from the previous step, where every undefined marked a place where no answer was ever given.

typeof null says object, and it is a bug

Both values should be able to say what they are. Predict what typeof calls null, then save:

Deno.test("typeof null says object, and it is a bug", () => {
assertEquals(typeof null, "null");
});
Check programs/nothing-twice.test.ts
running 3 tests from ./programs/nothing-twice.test.ts
undefined arrives when nothing was supplied ... ok (0ms)
null appears only because somebody put it there ... ok (0ms)
typeof null says object, and it is a bug ... FAILED (9ms)

ERRORS

typeof null says object, and it is a bug => ./programs/nothing-twice.test.ts:31:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- object
+ null

FAILURES

typeof null says object, and it is a bug => ./programs/nothing-twice.test.ts:31:11

FAILED | 2 passed | 1 failed (11ms)

error: Test failed

"object". The reasonable prediction fails, and this time the wrong answer is JavaScript's, not yours. Here is how it happened.

  1. In the original 1995 implementation, every value carried a small internal tag saying what kind of value it was, and the tag for an object was zero.
  2. null was represented as a machine null pointer, which is all zero bits.
  3. Reading null's tag therefore produced zero, the object tag, so typeof null answered "object", and it has answered "object" ever since.

Fixing it was formally proposed and rejected, because pages already relied on the wrong answer, and the web does not break working pages. So this is not a subtlety to understand. It is a thirty-year-old bug with tenure. Memorize it as an exception; nothing about it generalizes, and when you need to test for null, write value === null and never typeof.

Correct the prediction, and record the honest half alongside it:

Deno.test("typeof null says object, and it is a bug", () => {
assertEquals(typeof null, "object");
assertEquals(typeof undefined, "undefined");
});
typeof null says object, and it is a bug ... ok (0ms)

undefined gets a truthful typeof. null does not, and never will.

Why there are two at all

Most languages manage with one nothing. JavaScript has two because it borrowed the first and then discovered it was not enough.

null came from Java. In Java, a variable's starting value depends on its declared type: an object variable starts as null, and each primitive type has its own start, so an int starts as 0. In that arrangement null means one specific thing, "not an object", and nothing else needs to mean anything.

A JavaScript storage location has no declared type, so it can hold an object or a primitive. null could not serve as its starting value without quietly claiming the location was meant for objects. What was needed was a value that meant "not an object, not a primitive either, nothing has been put here yet", and undefined was invented for that job.

That history is the distinction at the top of this page. undefined is the value the language reaches for when nothing has been supplied. null is the one a programmer reaches for to say "deliberately nothing", because saying that is the job it was imported to do.

converted to a number, the two disagree

The two absences are both falsy, but hand them to an arithmetic conversion and they part ways. Predict all six lines:

Deno.test("converted to a number, the two disagree", () => {
assertEquals(Number(null), 0);
assertEquals(Number.isNaN(Number(undefined)), true);
assertEquals(Boolean(null), false);
assertEquals(Boolean(undefined), false);
assertEquals((null as any) >= 0, true);
assertEquals((null as any) == 0, false);
});
converted to a number, the two disagree ... ok (0ms)

Walk the lines.

  1. Number(null) is 0. The conversion treats null as a present value that converts cleanly, the way false converts to 0.
  2. Number(undefined) is NaN. There is nothing underneath undefined to convert, so the conversion itself fails. This is why a missing number poisons a total instead of quietly counting as zero, and of the two behaviors, failing louder is the better one.
  3. Boolean(null) and Boolean(undefined) are both false: on this one question, falsiness, the two agree.
  4. (null as any) >= 0 is true while (null as any) == 0 is false, because the two operators follow different rules. >= converts null to 0 and compares numbers. == refuses that conversion, since the equality page's step null == undefined, and nothing else passes established that null loosely equals only undefined.

There is no consistent story across those operators to learn. There is only the advice from the equality page, doubled: compare values of known types, and the as any widenings here are the tell that we left that safety behind on purpose.

reading a property throws only for these two

The most common runtime error in JavaScript belongs on this page, because these two values are the only ones that cause it. Predict which lines throw:

Deno.test("reading a property throws only for these two", () => {
assertThrows(
() => (undefined as any).note,
TypeError,
"Cannot read properties of undefined (reading 'note')",
);
assertThrows(
() => (null as any).note,
TypeError,
"Cannot read properties of null (reading 'note')",
);
assertEquals((true as any).note, undefined);
assertEquals((0 as any).note, undefined);
assertEquals(({} as { note?: string }).note, undefined);
});
reading a property throws only for these two ... ok (0ms)

assertThrows passes when the function it is handed throws the named error, and both of the first two do. The last three lines are the contrast that makes the claim precise.

  1. (undefined as any).note throws a TypeError. There is no object to look a property up on, so the read cannot answer.
  2. (null as any).note throws for the same reason. These are the only two values in the language that do this.
  3. (true as any).note, (0 as any).note, and ({}).note all answer undefined instead. Every other value, even ones with no properties of their own, would rather answer undefined than refuse, which is the first fact on this page wearing a new hat.

So reading a property is safe on anything except absence, and that makes the error message unusually informative. Read it carefully when you meet it, because it carries two clues.

First, which absence it names. The undefined version almost always means a value never arrived: a missing argument, a lookup that found nothing, a function that returned nothing. The null version more often means a value arrived and was deliberately empty: a query that matched no row, a regular expression that did not match. The two point at different bugs.

Second, what the name in the message is. In Cannot read properties of undefined (reading 'note'), the word note is the property you asked for, not the thing that was missing. The missing thing is whatever sat to the left of the dot. People misread this constantly and go hunting for a broken note. The guard for these throws is optional chaining, which we get to in optional chaining short-circuits on both.

JSON keeps null and loses undefined

Data leaves your program through JSON.stringify, and the two absences do not survive the trip equally. You have seen that an object property can hold undefined; predict what happens to one here, and then apply the same prediction to an array. Save:

Deno.test("JSON keeps null and loses undefined", () => {
assertEquals(JSON.stringify({ note: undefined }), "{}");
assertEquals(JSON.stringify({ note: null }), '{"note":null}');
assertEquals(JSON.stringify([undefined]), "[]");
});
Check programs/nothing-twice.test.ts
running 6 tests from ./programs/nothing-twice.test.ts
undefined arrives when nothing was supplied ... ok (0ms)
null appears only because somebody put it there ... ok (0ms)
typeof null says object, and it is a bug ... ok (0ms)
converted to a number, the two disagree ... ok (0ms)
reading a property throws only for these two ... ok (0ms)
JSON keeps null and loses undefined ... FAILED (8ms)

ERRORS

JSON keeps null and loses undefined => ./programs/nothing-twice.test.ts:61:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- [null]
+ []

FAILURES

JSON keeps null and loses undefined => ./programs/nothing-twice.test.ts:61:11

FAILED | 5 passed | 1 failed (10ms)

error: Test failed

The first two lines passed, so an undefined property vanished and a null property survived. Then the array broke the pattern: undefined in an array did not vanish. It became null. Correct the prediction to "[null]", and add the two remaining cases:

Deno.test("JSON keeps null and loses undefined", () => {
assertEquals(JSON.stringify({ note: undefined }), "{}");
assertEquals(JSON.stringify({ note: null }), '{"note":null}');
assertEquals(JSON.stringify([undefined]), "[null]");
assertEquals(JSON.stringify(undefined), undefined);
assertEquals(JSON.stringify(null), "null");
});
JSON keeps null and loses undefined ... ok (0ms)

Four separate behaviors for undefined, one root cause. Walk them:

  1. { note: undefined } becomes "{}". A property holding undefined disappears from the output entirely, so a round trip silently drops it.
  2. [undefined] becomes "[null]". Inside an array nothing can disappear, because removal would shift every later index, so the value is converted to null instead. Put undefined in, get null out.
  3. JSON.stringify(undefined) on its own returns the value undefined, not a string at all. Code that assumes stringify always produces text will fail here on a value it never expected.
  4. null serializes as the text null, faithfully, in every position.

The root cause: the JSON format has null and has no notion of undefined, so stringify must do something with a value it cannot write, and it does three different somethings depending on position. If your data crosses a JSON boundary, absence is spelled null on the wire, whatever you use in memory.

a default fires for undefined only

Parameter defaults look like they handle "no value". Be precise about which no-value they handle. Predict all three calls:

Deno.test("a default fires for undefined only", () => {
function withDefault(value: number | null = 10) {
return value;
}
assertEquals(withDefault(), 10);
assertEquals(withDefault(undefined), 10);
assertEquals(withDefault(null), null);
});
a default fires for undefined only ... ok (0ms)

Walk the three calls.

  1. withDefault() supplies nothing, the parameter holds undefined, and the default replaces it: 10.
  2. withDefault(undefined) passes undefined explicitly, and the default fires exactly the same way, because the default's trigger is the value undefined, not the argument count.
  3. withDefault(null) receives null and the default sits it out. null is a value you chose to pass, so the function receives exactly that, unchanged.

Read the third line twice, because it is the most common way these two values cause a real bug. The behavior is consistent with the top of this page, since undefined means "nothing was supplied" and null means "this was supplied, and it is null". But data arrives from JSON with null in it, because JSON keeps null and loses undefined established that absence crosses the wire as null, then flows into a function with a tidy default, and the default never runs.

?? treats only null and undefined as missing

Falling back to a default by hand is usually written with an operator, and there are two candidates. || was there first. Predict what it does with a discount of zero:

Deno.test("?? treats only null and undefined as missing", () => {
const discount: number | undefined = 0;
assertEquals(discount || 7, 0);
});
Check programs/nothing-twice.test.ts
running 8 tests from ./programs/nothing-twice.test.ts
undefined arrives when nothing was supplied ... ok (0ms)
null appears only because somebody put it there ... ok (0ms)
typeof null says object, and it is a bug ... ok (0ms)
converted to a number, the two disagree ... ok (0ms)
reading a property throws only for these two ... ok (0ms)
JSON keeps null and loses undefined ... ok (0ms)
a default fires for undefined only ... ok (0ms)
?? treats only null and undefined as missing ... FAILED (8ms)

ERRORS

?? treats only null and undefined as missing => ./programs/nothing-twice.test.ts:78:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- 7
+ 0

FAILURES

?? treats only null and undefined as missing => ./programs/nothing-twice.test.ts:78:11

FAILED | 7 passed | 1 failed (11ms)

error: Test failed

The discount of 0 came back as 7. || never asked whether the value was missing; it asked whether the value was falsy, and 0 is falsy, so || discarded a perfectly real discount and substituted the fallback. A hundred percent discount rendered as seven is || doing exactly what it says and not what you meant.

??, the nullish coalescing operator, was added to ask the right question: it falls back only for null and undefined, and treats every other value, including the falsy ones, as an answer worth keeping. Correct the prediction and put the two operators side by side:

Deno.test("?? treats only null and undefined as missing", () => {
const discount: number | undefined = 0;
assertEquals(discount || 7, 7);
assertEquals(discount ?? 7, 0);

const note: string | null = "";
assertEquals(note || "placeholder", "placeholder");
assertEquals(note ?? "placeholder", "");

const nothing: string | null = null;
assertEquals(nothing ?? "placeholder", "placeholder");
});
?? treats only null and undefined as missing ... ok (0ms)

Read the pairs.

  1. discount || 7 is 7 and discount ?? 7 is 0: || discards the zero, ?? keeps it.
  2. note || "placeholder" is "placeholder" and note ?? "placeholder" is "": same split for empty text, which is falsy and real.
  3. nothing ?? "placeholder" is "placeholder": on an actual null, the two operators agree, because null is missing by either definition.

Reach for ?? whenever 0, "", or false is a value your program should keep, which is most of the time a fallback is involved. || still has uses, but "provide a default" is rarely one of them. The question || actually asks is falsiness, and the truthiness page measures that question, operand by operand, in && and || return operands, not booleans.

optional chaining short-circuits on both

The step reading a property throws only for these two promised a guard. ?. is that guard: it checks the value to its left, and only if the value is null or undefined, it stops and answers undefined instead of reading further and throwing.

Deno.test("optional chaining short-circuits on both", () => {
const missing: { inner?: { deep: number } } = {};
assertEquals(missing.inner?.deep, undefined);

const nulled: { inner: { deep: number } | null } = { inner: null };
assertEquals(nulled.inner?.deep, undefined);
});
optional chaining short-circuits on both ... ok (0ms)

Follow each read.

  1. missing.inner is absent, so the read produces undefined, and ?. stops the chain there. The whole expression answers undefined instead of throwing on .deep.
  2. nulled.inner holds null, a deliberate nothing, and ?. treats it exactly the same way: stop, answer undefined.

Notice what the second line did: null went in, and undefined came out. ?. never answers null, whichever absence it met. This is one of several places where the language quietly nudges you toward undefined as the canonical absence, and the next step shows TypeScript giving the same nudge on purpose.

an optional property means undefined, not null

TypeScript has picked a side in this entry's distinction. Deno type-checks with strict null checking on, so neither absence slips into an ordinary type unannounced. The sharper fact is what the ? in an optional property means. Try to put null into one:

Deno.test("an optional property means undefined, not null", () => {
type Order = { note?: string };
const nulled: Order = { note: null };
});
Check programs/nothing-twice.test.ts
TS2322 [ERROR]: Type 'null' is not assignable to type 'string | undefined'.
const nulled: Order = { note: null };
~~~~
at file:///programs/nothing-twice.test.ts:101:29

The expected type comes from property 'note' which is declared here on type 'Order'
type Order = { note?: string };
~~~~
at file:///programs/nothing-twice.test.ts:100:20

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Read the diagnostic's first line: the type of note? is string | undefined. The ? invited undefined in, and null was not invited. If you want null, you write it into the type yourself, as a union. Replace the test with both shapes:

Deno.test("an optional property means undefined, not null", () => {
type Order = { note?: string };
const absent: Order = {};
const explicit: Order = { note: undefined };
assertEquals(absent.note, undefined);
assertEquals(explicit.note, undefined);

type NullableOrder = { note: string | null };
const chosen: NullableOrder = { note: null };
assertEquals(chosen.note, null);
});
an optional property means undefined, not null ... ok (0ms)

Three shapes, three statements of intent.

  1. absent omits the property entirely, and Order allows that: the ? makes omission legal.
  2. explicit writes note: undefined, and Order allows that too, because the optional property's type is string | undefined.
  3. chosen carries null only because NullableOrder declared string | null, a deliberate widening you had to write out.

The syntax is the advice. TypeScript made undefined the cheap default absence and null a declaration you must spell out, which is a fair one-line summary of this whole page.

in sees the property that reads cannot

The previous step created two objects that are about to cause trouble: one with no note at all, and one with note present and holding undefined. Predict which of the five assertions can tell them apart:

Deno.test("in sees the property that reads cannot", () => {
const absent: { note?: string } = {};
const explicit: { note?: string } = { note: undefined };
assertEquals(absent.note, undefined);
assertEquals(explicit.note, undefined);
assertEquals("note" in absent, false);
assertEquals("note" in explicit, true);
assertEquals(JSON.stringify(absent), JSON.stringify(explicit));
});
in sees the property that reads cannot ... ok (0ms)

Walk the evidence.

  1. absent.note and explicit.note both read undefined. Property reads compare what a read produces, and the reads produce the same value, so reading cannot tell the objects apart.
  2. "note" in absent is false and "note" in explicit is true. in asks a different question: does the key exist? One object has the key and one does not, so in tells them apart.
  3. JSON.stringify renders both objects as "{}", because JSON keeps null and loses undefined established that an undefined property is dropped. The wire format erases the difference that in can see.

The equality page met this same pair in a property holding undefined is not a missing property, where deep equality sided with in and called the two objects different. So the difference is real, three tools observe it, and one boundary erases it. Treat it as an implementation detail rather than something to encode meaning in: if a distinction matters to your program, do not store it somewhere JSON will destroy.

In practice