Learn TypeScript - Type Guards, Narrowing, and Type Flow Control
Episode 11 of 23

Learn TypeScript - Type Guards, Narrowing, and Type Flow Control

This episode covers narrowing union types down to concrete types: narrowing with typeof, truthiness, equality, in, and instanceof, plus type predicates and discriminated unions. You'll also learn exhaustive checks with never.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A union type gives flexibility, but using its members requires care. TypeScript doesn't let an operation that only applies to one union member be called without proof. The process of narrowing from a wide type to a narrow one is called type narrowing.

Narrowing works by reading the flow of control. Every consistent check is used by the compiler to narrow the type in a specific branch. Being able to read narrowing is crucial because unions, nullable values, and API data are everywhere.

Episode 11 covers narrowing techniques from the simplest to advanced patterns: the typeof operator, truthiness, equality, in, instanceof, type predicates, and discriminated unions. You'll also close gaps with exhaustive checks.

Narrowing with typeof and Truthiness

typeof for Primitives

The typeof operator is the first narrow the compiler recognizes:

Narrowing dengan typeof
function proses(input: string | number): string {
    if (typeof input === "string") {
        return input.toUpperCase();
    }
    return input.toFixed(2);
}

Inside the if block, the compiler knows input is definitely a string, so toUpperCase is safe to call. Outside the block, the type automatically narrows to number. The check typeof input === "string" doesn't just work as logic; it also serves as proof for the compiler.

Truthiness for Null and Undefined

A direct check removes empty values:

Narrowing dengan truthiness
function sapa(nama?: string): string {
    if (nama) {
        return `Halo, ${nama}`;
    }
    return "Halo, tamu";
}

The if (nama) block narrows string | undefined to string. Watch out for empty values like the empty string, which are also falsy. For certain cases, check explicitly with !== undefined instead of relying on truthiness.

Equality and the in Operator

Equality for Literals and Unions

Equality comparisons also narrow:

Equality narrowing
type Status = "aktif" | "nonaktif" | "pending";
 
function pesan(status: Status): string {
    if (status === "aktif") {
        return "Akun berjalan";
    }
    if (status === "pending") {
        return "Menunggu konfirmasi";
    }
    return "Akun dihentikan";
}

Each literal comparison discards one union member. With the three statuses above, the final branch is automatically typed "nonaktif". This pattern is most powerful when combined with the discriminated union below.

The in Operator for Object Properties

To tell object shapes apart, use the in operator:

Narrowing dengan in
interface Pesawat {
    sayap: number;
}
 
interface Mobil {
    roda: number;
}
 
function deskripsi(kendaraan: Pesawat | Mobil): string {
    if ("sayap" in kendaraan) {
        return `Pesawat dengan ${kendaraan.sayap} sayap`;
    }
    return `Mobil dengan ${kendaraan.roda} roda`;
}

The in operator checks whether a property exists. The presence of sayap proves the shape is Pesawat. This approach works well when the two interfaces don't share a distinguishing property.

instanceof and Type Predicates

instanceof for Classes

Narrowing against class instances uses instanceof:

instanceof narrowing
class ApiError extends Error {}
class JaringanError extends Error {}
 
function tangani(err: Error): string {
    if (err instanceof ApiError) {
        return "Gagal memproses request";
    }
    return "Gangguan jaringan";
}

instanceof checks the prototype chain and narrows the type to a specific class. It's useful when handling error hierarchies or objects from class-based libraries.

Type Predicates with is

When a check is too complex for the compiler, you can write your own guard function:

Type predicate
function adalahObjekPesan(nilai: unknown): nilai adalah { pesan: string } {
    return typeof nilai === "object"
        && nilai !== null
        && "pesan" in nilai;
}

The signature nilai adalah { pesan: string } tells the compiler that a true return means the argument satisfies that shape. Type predicates make check logic reusable and easy to read in many places.

Discriminated Unions

The most powerful pattern: a union of objects sharing the same discriminating field:

Discriminated union
type Aksi =
    | { jenis: "simpan"; data: string }
    | { jenis: "hapus"; id: number };
 
function jalankan(a: Aksi): string {
    switch (a.jenis) {
        case "simpan":
            return `Menyimpan: ${a.data}`;
        case "hapus":
            return `Menghapus id: ${a.id}`;
    }
}

The jenis field is the discriminator. In every case, the compiler knows the exact object shape, so a.data and a.id can be accessed without error. Discriminated unions are the safest way to model state machines and events.

Exhaustive Checks with never

To make sure every possibility is handled, mark the default with never:

Exhaustive check
function pastikanTakMungkin(x: never): never {
    throw new Error(`Nilai tak tertangani: ${x}`);
}

The pastikanTakMungkin function only accepts never; in the default branch of the switch above, call this function with the variable a. If a new union member is added later, the variable a in the default branch is no longer never, so compilation fails. The compiler reminds you to handle the new case, right at the correct point in the code.

Tip

Combining a discriminated union with a switch and an exhaustive check is the safest pattern for state machines. Changes to the model immediately raise compile errors everywhere that state is handled.

Closing

Episode 11 turns you from a union user into a union controller. With typeof, truthiness, equality, in, instanceof, type predicates, and discriminated unions, you can use union values with full confidence.

Key takeaways:

  • Narrowing narrows a union type based on checks in the flow of control.
  • typeof for primitives, truthiness for empty values.
  • Equality narrows literals; in distinguishes object shapes.
  • instanceof narrows class instances; type predicates use is.
  • Discriminated unions make switch safe per branch.
  • Exhaustive checks with never force handling all cases.

In the next episode 12 we'll discuss advanced mapped types and conditional types — tools to transform existing types into new types programmatically.

Learn TypeScript - Type Guards, Narrowing, and Type Flow Control | Learn TypeScript