null, undefined and Strict Null Checks
How strictNullChecks catches the most common JavaScript error, optional properties and chaining, nullish coalescing, and why the non-null assertion is a last resort.
The problem: two kinds of nothing
JavaScript has two values for "no value":
undefined and null. A missing property, a Map lookup that finds nothing,
Array.find without a match and a parameter that was not
passed all give undefined; APIs and the DOM often use
null. Reading a property of either one throws the
single most common JavaScript run-time error:
TypeError: Cannot read properties of undefined (reading 'toFixed')
The general problem of "null values" in programming languages is described on Types and Type Systems. TypeScript's answer is a compiler setting.
Strict null checks
With
strict null checks (the
compiler option strictNullChecks),
null and undefined are types of their own
and are no longer part of every other type. A number is
then really a number. A value that may be missing must say so with a
union, such as number | null:
const kWh: number = null;
example.ts(1,7): error TS2322: Type 'null' is not assignable to type 'number'.
let lastReading: number | null = null;
console.log(lastReading);
lastReading = 4.2;
console.log(lastReading.toFixed(1));
null 4.2
The second example compiles because the compiler sees that
lastReading was just assigned a number; that is
narrowing at work.
null and undefined. With them, missing
values must be part of the type you write.
Turning it on
strictNullChecks is one of the checks switched on by
"strict": true in tsconfig.json, and since
TypeScript 6.0 strict is on by default (see
Setting Up). Older projects may still have it off, and the difference is
dramatic: with --strictNullChecks false, this file
compiles without a single error, and both mistakes reach production.
const kWh: number = null;
function firstWord(text: string | undefined): string {
return text.split(" ")[0];
}
npx tsc --ignoreConfig --strictNullChecks false --noEmit b.ts
echo $?
0
If you inherit such a project, turning the option on usually produces many errors at once. They are worth fixing: each one marks a place where the code assumes a value is present without checking. Moving an existing code base step by step is covered on Tooling, Configuration and Migrating from JavaScript.
"Possibly undefined": where it comes from
With strict null checks on, the compiler knows which standard functions can return nothing, and reports any use of their result that does not handle it. The typical sources:
-
Map.get(key)returnsV | undefined. -
array.find(…)returnsT | undefined. -
document.querySelector(…)returnsElement | null(in the browser). -
Optional properties
and optional parameters are
T | undefined.
const readings = new Map<string, number>([
["E-1001", 4.2],
["E-1002", 12.0],
]);
const kWh = readings.get("E-1003");
console.log(kWh.toFixed(1));
example.ts(7,13): error TS18048: 'kWh' is possibly 'undefined'.
interface Ticket {
id: string;
priority: number;
}
const tickets: Ticket[] = [
{ id: "INC-30412", priority: 2 },
{ id: "INC-30415", priority: 1 },
];
const p1 = tickets.find((t) => t.priority === 1);
console.log(p1.id);
example.ts(11,13): error TS18048: 'p1' is possibly 'undefined'.
In the browser, the same protection covers the DOM: an element you look up may not be on the page. The message reads TS18047 for a variable, or TS2531 ("Object is possibly 'null'") when the call is used directly:
const status = document.querySelector("#status");
status.textContent = "All meters online";
example.ts(2,1): error TS18047: 'status' is possibly 'null'.
Handling missing values
Every fix is ordinary JavaScript that narrows the type. Choose the one that says what should happen when the value is missing.
Check, then skip or return early
An explicit comparison with undefined (or
null) is the clearest choice when a missing value needs
its own handling. After the check, the rest of the block sees the
narrowed type:
const readings = new Map<string, number>([
["E-1001", 4.2],
["E-1002", 12.0],
]);
for (const id of ["E-1001", "E-1003"]) {
const kWh = readings.get(id);
if (kWh === undefined) {
console.log(`${id}: no reading`);
continue;
}
console.log(`${id}: ${kWh.toFixed(1)} kWh`);
}
E-1001: 4.2 kWh E-1003: no reading
When a missing value means the program cannot continue, throw. The function's return type then promises a real value, and callers do not have to check again:
function lookup(id: string): string | undefined {
return id === "E-1001" ? "Utrecht" : undefined;
}
function cityOf(id: string): string {
const city = lookup(id);
if (!city) {
throw new Error(`Unknown meter ${id}`);
}
return city;
}
console.log(cityOf("E-1001").toUpperCase());
UTRECHT
Optional chaining and defaults
For "use it if it is there", JavaScript has two operators that
TypeScript understands fully.
Optional chaining
a?.b stops and gives undefined when
a is null or undefined; the
nullish coalescing operator
a ?? b gives b in exactly those two cases.
Together they read a nested, optional value with a fallback, and the
result type no longer includes undefined:
interface Customer {
name: string;
address?: { street: string; city: string };
phone?: string;
}
const customers: Customer[] = [
{ name: "Amara Okafor", address: { street: "Oudegracht 12", city: "Utrecht" } },
{ name: "Joost Bakker", phone: "+31 15 555 0142" },
];
for (const c of customers) {
const city = c.address?.city ?? "unknown city";
console.log(`${c.name}: ${city}, phone ${c.phone ?? "none"}`);
}
Amara Okafor: Utrecht, phone none Joost Bakker: unknown city, phone +31 15 555 0142
interface Settings {
retries?: number;
label?: string;
}
function describe(s: Settings): string {
const a = s.retries || 3;
const b = s.retries ?? 3;
const c = s.label || "(none)";
const d = s.label ?? "(none)";
return `|| ${a}, ?? ${b}, || "${c}", ?? "${d}"`;
}
console.log(describe({}));
console.log(describe({ retries: 0, label: "" }));
|| 3, ?? 3, || "(none)", ?? "(none)" || 3, ?? 0, || "(none)", ?? ""
Optional parameters and properties
A parameter marked with ? (see
Functions) or a property marked with
? has the type you wrote plus undefined.
The same rules apply: check before use.
function greet(name?: string): string {
return `Hello, ${name.toUpperCase()}`;
}
example.ts(2,20): error TS18048: 'name' is possibly 'undefined'.
function greet(name?: string): string {
if (name === undefined) {
return "Hello, customer";
}
return `Hello, ${name}`;
}
console.log(greet("Amara"));
console.log(greet());
Hello, Amara Hello, customer
note?: string and
note: string | undefined are close but not the same.
The first lets the caller leave the property out; the second
requires the property to be present, even if its value is
undefined. By default, TypeScript also lets you
write { note: undefined } for an optional
property. The stricter option
exactOptionalPropertyTypes (switched on by
tsc --init since TypeScript 5.9) forbids that, so
"absent" and "present but undefined" stay distinct, which matters
when you send JSON where a missing key and a null mean
different things (see
null vs. missing).
The non-null assertion: a last resort
The
non-null assertion operator
is a ! after an expression: text! tells
the compiler "this is not null or
undefined, trust me". It removes the error, but it
checks nothing. The compiled JavaScript simply drops the
!. This source:
function firstWord(text: string | undefined): string {
return text!.split(" ")[0];
}
compiles to this JavaScript (TypeScript 7.0.2, without the two CommonJS header lines):
function firstWord(text) {
return text.split(" ")[0];
}
So when you are wrong, you get exactly the crash strict null checks were meant to prevent:
function firstWord(text: string | undefined): string {
return text!.split(" ")[0];
}
console.log(firstWord("Meter replaced in Utrecht"));
console.log(firstWord(undefined));
Meter TypeError: Cannot read properties of undefined (reading 'split')
Legitimate uses exist: a value that a framework sets before your
code runs, or an element that your own HTML always contains. Even
then, a check that throws a clear error (if (!el) throw new Error("#status missing")) costs one line and fails with a message that points at the cause.
Many teams forbid ! with the typescript-eslint rule
no-non-null-assertion.
Definite assignment
Strict mode also checks that variables and class fields are assigned
before they are used. A class field that the constructor never sets
would be undefined, while its type promises a string:
class MeterClient {
baseUrl: string;
token: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
}
example.ts(3,3): error TS2564: Property 'token' has no initializer and is not definitely assigned in the constructor.
The right fixes are to give the field a value, set it in the
constructor, or admit it can be missing (token: string | undefined
or token?: string). Sometimes the field
is set, but in a way the compiler cannot follow, such as a
method called from the constructor or a framework that fills it in.
For that case there is the
definite assignment assertion, a ! after the field or variable name:
class MeterClient {
baseUrl: string;
token!: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.init();
}
init(): void {
this.token = "demo-token";
}
}
const client = new MeterClient("http://127.0.0.1");
console.log(client.token);
demo-token
Like the non-null assertion, it is a promise without a check: if
init stopped setting the token, nothing would warn you.
Local variables get the same treatment; using a
let before any assignment is error TS2454 ("Variable
'total' is used before being assigned"). More about fields and
constructors is on Classes.
noUncheckedIndexedAccess
One gap remains with strict alone. Reading an array
element by index, or a key of an
index signature, is assumed to succeed. An empty array has no element 0, yet this
compiles:
const daily: number[] = [];
console.log(daily[0].toFixed(1));
TypeError: Cannot read properties of undefined (reading 'toFixed')
The compiler option
noUncheckedIndexedAccess
closes that gap: every index access gets
| undefined added to its type, so you must check it
like any other possibly missing value. It is not part of
strict, because it makes a lot of correct code noisier
(the checker of this site does not enable it), but
tsc --init (since TypeScript 5.9) turns it on for new
projects:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
const daily: number[] = [];
console.log(daily[0].toFixed(1));
const tariffs: Record<string, number> = { DAY: 0.32, NIGHT: 0.25 };
const price = tariffs["WEEKEND"];
console.log(price.toFixed(2));
for (const kWh of daily) {
console.log(kWh.toFixed(1));
}
npx tsc --noEmit
meters.ts(2,13): error TS2532: Object is possibly 'undefined'. meters.ts(6,13): error TS18048: 'price' is possibly 'undefined'.
Note what is not reported: the for…of loop.
Iterating only visits elements that exist, so kWh is a
plain number. Preferring for…of,
map and find over index arithmetic keeps
the extra checks to a minimum.
Putting it together
| Situation | Write |
|---|---|
| A value may be missing |
Put it in the type: number | undefined,
email?: string
|
| Missing needs its own handling |
if (x === undefined) { … return; }
|
| Missing is an error | if (!x) throw new Error("…") |
| Use it if present, else a default | a?.b ?? fallback |
| 0, "" or false are valid values | ??, never || |
| You know better than the compiler | x! or field!: T, sparingly |
| Indexes and lookup tables |
noUncheckedIndexedAccess, or a
Map
|
One limit applies to all of it: strict null checks only know what
the types say. A JSON response typed as
{ email: string } can still contain
null at run time. Data from outside the program must be
validated before you trust its types (see
Async Code and Typing API Data).