Skip to content
Theory · Narrowing · lesson 3 of 12

Null checks and equality

Let's go back to the sorting belt. Sometimes a tray reaches the arch empty. Before deciding whether it holds an envelope or a can, the worker checks whether there is anything in the tray at all. An empty tray is set aside, and only real parcels ride on down the belt.

In a program, the empty tray is null or undefined. You already know that a value of type string | null does not let you call a string method until null is ruled out:

TypeScript
function greet(nick: string | null) {
    console.log("Hi, " + nick.toUpperCase()); // ✗
}
TEXT
error TS18047: 'nick' is possibly 'null'.

For null and undefined you do not need a typeof check. TypeScript narrows the type after two other checks as well: a truthiness condition and a comparison.

The truthiness check

The condition if (nick) lets only truthy values into the branch. In JavaScript, null and undefined are falsy, so tsc removes them from the type inside the if:

TypeScript
function greet(nick: string | null) {
    if (nick) {
        console.log("Hi, " + nick.toUpperCase());
    } else {
        console.log("Hi, guest");
    }
}

greet("ann"); // Hi, ANN
greet(null);  // Hi, guest
greet("");    // Hi, guest

Let's walk through the example line by line:

  1. Inside the if, nick has the type string: null never gets here, and toUpperCase compiles.
  2. greet("ann") prints Hi, ANN.
  3. greet(null) goes to the else and prints Hi, guest.
  4. greet("") prints Hi, guest too: an empty string is falsy in JavaScript.

The last call matters. In the else, nick has the type string | null, not null: the empty string ends up there as well. A truthiness condition cuts off every falsy JavaScript value at once:

  • null and undefined;
  • false;
  • 0, 0n and NaN;
  • the empty string "".

Here the tray picture parts ways with TypeScript. An empty string and zero are not empty trays but real parcels, yet the truthiness check sets them aside together with the empty trays.

Comparing with = and !

When only null must be cut off, you compare with it explicitly. TypeScript understands both === and !==:

TypeScript
function badge(unread: number | null) {
    if (unread !== null) {
        return "unread: " + unread;
    }
    return "loading";
}

function same(a: string | number, b: string | boolean) {
    if (a === b) {
        return a.toUpperCase();
    }
    return "-";
}

console.log(badge(0), same("ok", "ok")); // unread: 0 OK

The program prints unread: 0 OK. Let's go through both functions.

Comparing with null

unread !== null removes exactly null from the type. Inside the if, unread has the type number, and zero stays a real number, so badge(0) returns unread: 0. After the return, unread is left with the type null.

For undefined you write the same: x !== undefined. But typeof x === "object" does not work for this: in JavaScript typeof null gives "object" too, and tsc remembers it, so null stays in that branch.

Comparing two values

You can also compare two values with union types. If a === b is true, a and b hold the same value, so its type is one that appears in both unions:

  • a has the variants string and number;
  • b has the variants string and boolean;
  • they share only one variant—string.

So inside the if, tsc treats both a and b as strings, and a.toUpperCase() compiles.

Common mistake

A number is checked for truthiness to cut off null:

TypeScript
function badge(unread: number | null) {
    if (unread) { // ✗
        return "unread: " + unread;
    }
    return "loading";
}

console.log(badge(0));

tsc is silent: inside the if, unread has the type number, and everything is legal. But the program prints loading, even though the message count is known—it is zero. Zero is falsy, and the condition sent it to the same branch as null. When 0 or an empty string is a normal value, check unread !== null, as in the example above. Interviewers like to test this trap.

Key points

  • if (x) removes null and undefined from the type, but it also cuts off the other falsy values: 0, "", NaN, false.
  • x !== null removes exactly null from the type, and zero and the empty string stay in the branch.
  • If a === b, inside the if both values have the type their unions share.
  • tsc will not warn you that if (unread) sent zero to the "no value" branch: this is a logic mistake, not a type mistake.