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.
uninitializedwas declared and never assigned, so reading it producesundefined: the language filling a gap you left open.record.missingasks the object for a key it does not have, so the read producesundefinedinstead of refusing to answer.twoItems[5]asks a two-element array for its sixth element, and an index past the end producesundefinedthe same way an absent key does.returnsNothing()has noreturnstatement, so the call still produces a value, and that value isundefined.takesMaybe()was called without its argument, so inside the function the parametervalueholdsundefined.
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.
chosenholdsnullbecause the code assigned it, which is the ordinary waynullenters a program: on purpose."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 answernull.JSON.parse("null")read the four charactersn,u,l,land produced the value they name. Nothing was missing; the text saidnull.Object.getPrototypeOf(Object.prototype)walked to the top of the prototype chain and found its end, and the end of the chain is marked withnull.
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.
- 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.
nullwas represented as a machine null pointer, which is all zero bits.- Reading
null's tag therefore produced zero, the object tag, sotypeof nullanswered"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.
Number(null)is0. The conversion treatsnullas a present value that converts cleanly, the wayfalseconverts to0.Number(undefined)isNaN. There is nothing underneathundefinedto 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.Boolean(null)andBoolean(undefined)are bothfalse: on this one question, falsiness, the two agree.(null as any) >= 0istruewhile(null as any) == 0isfalse, because the two operators follow different rules.>=convertsnullto0and compares numbers.==refuses that conversion, since the equality page's step null == undefined, and nothing else passes established thatnullloosely equals onlyundefined.
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.
(undefined as any).notethrows aTypeError. There is no object to look a property up on, so the read cannot answer.(null as any).notethrows for the same reason. These are the only two values in the language that do this.(true as any).note,(0 as any).note, and({}).noteall answerundefinedinstead. Every other value, even ones with no properties of their own, would rather answerundefinedthan 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:
{ note: undefined }becomes"{}". A property holdingundefineddisappears from the output entirely, so a round trip silently drops it.[undefined]becomes"[null]". Inside an array nothing can disappear, because removal would shift every later index, so the value is converted tonullinstead. Putundefinedin, getnullout.JSON.stringify(undefined)on its own returns the valueundefined, not a string at all. Code that assumesstringifyalways produces text will fail here on a value it never expected.nullserializes as the textnull, 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.
withDefault()supplies nothing, the parameter holdsundefined, and the default replaces it:10.withDefault(undefined)passesundefinedexplicitly, and the default fires exactly the same way, because the default's trigger is the valueundefined, not the argument count.withDefault(null)receivesnulland the default sits it out.nullis 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.
discount || 7is7anddiscount ?? 7is0:||discards the zero,??keeps it.note || "placeholder"is"placeholder"andnote ?? "placeholder"is"": same split for empty text, which is falsy and real.nothing ?? "placeholder"is"placeholder": on an actualnull, the two operators agree, becausenullis 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.
missing.inneris absent, so the read producesundefined, and?.stops the chain there. The whole expression answersundefinedinstead of throwing on.deep.nulled.innerholdsnull, a deliberate nothing, and?.treats it exactly the same way: stop, answerundefined.
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.
absentomits the property entirely, andOrderallows that: the?makes omission legal.explicitwritesnote: undefined, andOrderallows that too, because the optional property's type isstring | undefined.chosencarriesnullonly becauseNullableOrderdeclaredstring | 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.
absent.noteandexplicit.noteboth readundefined. Property reads compare what a read produces, and the reads produce the same value, so reading cannot tell the objects apart."note" in absentisfalseand"note" in explicitistrue.inasks a different question: does the key exist? One object has the key and one does not, sointells them apart.JSON.stringifyrenders both objects as"{}", because JSON keeps null and loses undefined established that anundefinedproperty is dropped. The wire format erases the difference thatincan 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
- Let the language produce
undefinedand read it as “nothing was supplied” instead of assigning it explicitly. - Choose one project rule for
null: convert it at boundaries, or reserve it for deliberate absence and useundefinedfor accidental absence. - Use
??instead of||when zero or an empty string is a legitimate value. - Check both absences with
value == nullonly if the project allows that idiom; otherwise spell out both comparisons. - Test specifically for
nullwith=== null, never withtypeof. - At JSON boundaries, expect missing values as
null, omittedundefinedproperties, and array entries changed fromundefinedtonull.