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.

The type number with and without strict null checks. On the left, with strictNullChecks off, null and undefined sit inside every type, so the type number silently includes them. On the right, with strictNullChecks on, number contains only numbers; null and undefined are separate types outside it, and the union number or undefined has to be written to include one of them. strictNullChecks off number 4.2, 0, -1, NaN, … null undefined hidden inside strictNullChecks on number 4.2, 0, -1, NaN, … null undefined write number | undefined to allow one
Without strict null checks, every type secretly includes 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) returns V | undefined.
  • array.find(…) returns T | undefined.
  • document.querySelector(…) returns Element | 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).