Null, undefined, and Strict Null Checks
Two different kinds of nothing, the errors they cause at runtime, and how strict null checking turns that whole category of crash into a compile error.
Two different kinds of nothing, the errors they cause at runtime, and how strict null checking turns that whole category of crash into a compile error.
The most common error in this family of languages reads:
TypeError: Cannot read properties of null (reading 'name')Something was expected to be there and was not. A lookup found no match, an optional field was never filled in, a list came back empty. The program carried on as though it had a value, asked that value a question, and stopped.
This lesson is about the two ways a value can be absent, and about one compiler setting that converts that entire category of runtime crash into a message you see while typing. It is the single strongest argument for using this language.
let selected: string | null = null; // deliberately empty
let notSetYet; // undefinednull means "there is no value here, and that is
intentional". You write it yourself: a customer with no middle
name, a search that found nothing.
undefined means "no value has been provided". You rarely
write it — the language produces it:
let name; // declared, never assigned
customer.middleName; // a property that is not there
list[99]; // a position past the end
function noReturn() {}
noReturn(); // a function that returns nothingA decision somebody made.
You write it yourself: a customer with no middle name, a search that found nothing and says so.
A question nobody answered.
The language produces it whether you want it or not — an unassigned name, a missing property, a position past the end, a function that returns nothing.
In tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}strict turns on several checks; the one that matters most here
is strictNullChecks. Without it, null and undefined are
allowed anywhere:
// strictNullChecks OFF
const name: string = null; // accepted
name.toUpperCase(); // crashes at runtimeWith it on, absence is part of the type and has to be declared:
const name: string = null;
// Type 'null' is not assignable to type 'string'.
const name: string | null = null; // this is what you meantstring | null is a union: a value that is one of several
types. Now the checker knows this can be absent, and refuses to
let you ignore it:
function greet(name: string | null): string {
return `Hello, ${name.toUpperCase()}`;
// ~~~~
// 'name' is possibly 'null'.
}That error is the crash from the top of this lesson, reported
before anything ran. Turn strict on in every project, from
the first day. Adding it to an existing codebase produces
hundreds of errors, and each one is a place that could crash.
The checker will not let you use a possibly-absent value. It will let you use it once you have proven it is there:
function greet(name: string | null): string {
if (name === null) {
return "Hello, stranger";
}
return `Hello, ${name.toUpperCase()}`; // fine - null is ruled out
}Inside the if, name is null. After it, TypeScript knows
the only remaining possibility is string, so the method call
is allowed. That is narrowing, and it is the central
mechanism of the type system — the unions lesson develops it
properly.
You do not have to check for each separately:
if (name == null) { ... } // both null and undefined
if (name != null) { ... } // neitherThis is the one place the converting == earns its keep, and it
is worth a comment if your team is strict about ===.
The early return is usually the clearest shape:
function greet(name: string | null | undefined): string {
if (name == null) return "Hello, stranger";
return `Hello, ${name.toUpperCase()}`;
}Handle the absent case, leave, and let the rest of the function work with a value it knows exists.
Bad — telling the checker to stop worrying.
function getCity(customer: Customer): string {
return customer.address!.city!;
}Good — handling the case, or saying it cannot happen and why.
function getCity(customer: Customer): string {
return customer.address?.city ?? "unknown";
}The ! is the non-null assertion: it tells the checker "I
know this is not null". It performs no check and generates no
code — it is you overruling the only thing protecting you.
So the first version compiles cleanly and crashes at runtime for any customer without an address, with exactly the error this lesson opened with. Worse, it looks more confident than the unchecked version would have, so a reviewer reads it as deliberate.
There is a narrow legitimate use — you know something the checker cannot, such as a value populated by a framework before your code runs. Even then, prefer a check that also documents the assumption:
if (customer.address == null) {
throw new Error(`customer ${customer.id} has no address`);
}
return customer.address.city; // narrowed, and it says whyThat throws a message naming the problem instead of a
TypeError naming a property.
type Customer = {
id: string;
name: string;
middleName?: string; // may be absent
};? makes a property optional, which means its type is
string | undefined. Reading it gives undefined when absent,
and the checker requires you to handle that.
The same for parameters:
function greet(name: string, title?: string): string {
return title == null ? `Hello, ${name}` : `Hello, ${title} ${name}`;
}Optional parameters must come after required ones.
There is a real difference between an optional property and one explicitly typed as possibly-undefined:
type A = { middleName?: string }; // may be missing
type B = { middleName: string | undefined }; // must be presentB requires you to write the key, even if the value is
undefined. That matters when something distinguishes "not
provided" from "provided as empty" — an update that should leave
a field alone versus one that should clear it.
Four places produce most of it, and knowing them tells you where to put the checks.
Lookups that find nothing
customers.find(...) is Customer | undefined, always. The
checker tells you, every time.
Reading past the end of a list
By default the checker believes list[0] is a value, which
is a known unsoundness. noUncheckedIndexedAccess makes it
T | undefined and is worth turning on.
Data from a form, a file, or an API
The checker believes whatever you declared and nothing verified it. A later lesson is entirely about validating at this boundary.
Functions with no return value
They give undefined, which is easy to forget when you
assign the result of one to a name.
Put your handling where the absence enters. A value checked once at the edge is a value the rest of your code can use freely, which is far better than every function defending itself.
TWO KINDS
null a decision somebody made you write it
undefined a question nobody answered the language produces it
undefined comes from: unassigned names, missing properties,
out-of-range positions, functions with no return
pick ONE for your own code; be consistent
THE SETTING
"strict": true in tsconfig.json, from day one
without it, null goes anywhere and crashes at runtime
with it, absence is part of the TYPE and must be declared
string | null a union: one of several types
NARROWING
if (name === null) return ...; after this, it is a string
if (name == null) both null AND undefined
if (name != null) neither
early return, then work with a value you know exists
THE ESCAPE HATCH
value! asserts non-null; checks NOTHING
it compiles and crashes at runtime, and reads as deliberate
prefer ?. and ??, or a throw that names the problem
OPTIONAL
{ middleName?: string } may be absent
{ middleName: string | undefined } must be present, may be undefined
function f(a: string, b?: string) optional comes last
WHERE ABSENCE COMES FROM
.find() T | undefined, always
list[0] checked only with noUncheckedIndexedAccess
data from outside the checker believes your declaration
no-return functions undefined
handle it where it ENTERS, not in every function
THE LIMIT
a type is a claim about your code, not a check on runtime dataYou have met the mechanism that removes the most common runtime
crash in this family of languages, and the one operator that
switches it back off. strict: true and never writing ! are
two decisions worth making now and not revisiting.
Next is Arrays and Tuples, the first collections — many values under one name, the operations for working through them, and the position where absence appears again in a way the checker does not warn about by default.
Before you move on, open your tsconfig.json and confirm
"strict": true is set. Then write a function taking
string | null, use the parameter without checking, and read
the error. Then add the check and watch the error disappear as
you type the if. That is the type system doing the thing it
exists for.