Conditional Branching #

Conditional branching is the mechanism that determines the execution path of code based on a condition — the foundation of almost all program logic. TypeScript inherits all conditional constructs from JavaScript (if-else, switch, ternary), but adds a very important dimension: type narrowing. Every time you write a condition in TypeScript, the compiler automatically narrows the variable’s type inside the condition block based on that condition’s context. Understanding how narrowing works — and how to write effective type guards — is a key skill that separates ordinary TypeScript from TypeScript that truly exploits its type system to the fullest.

if-else and Automatic Type Narrowing #

if-else is the most fundamental conditional construct. In TypeScript, every condition expression you write gives type information to the compiler — it “narrows” the variable’s type based on what the condition has already established.

function prosesInput(nilai: string | number | null): string {
  // Here: nilai is string | number | null

  if (nilai === null) {
    // Here: TypeScript knows nilai is definitely null
    return "Tidak ada nilai";
  }

  // Here: TypeScript knows nilai is definitely string | number (null is excluded)

  if (typeof nilai === "string") {
    // Here: TypeScript knows nilai is definitely a string
    return nilai.toUpperCase(); // ✓ .toUpperCase() is available
  }

  // Here: TypeScript knows nilai is definitely a number
  return nilai.toFixed(2); // ✓ .toFixed() is available
}

Narrowing happens automatically based on the various kinds of checks TypeScript understands:

type Status = "aktif" | "nonaktif" | "pending" | null | undefined;

function deskripsikanStatus(status: Status): string {
  // Narrowing with equality
  if (status === "aktif") {
    return "Pengguna sedang aktif"; // status: "aktif"
  }

  if (status === null || status === undefined) {
    return "Status tidak diketahui"; // status: null | undefined
  }

  // Here: status is definitely "nonaktif" | "pending"
  if (status === "nonaktif") {
    return "Pengguna tidak aktif"; // status: "nonaktif"
  }

  // Here: TypeScript knows status is definitely "pending"
  return "Menunggu konfirmasi"; // status: "pending"
}

The Early Return Pattern — Reducing Nesting #

One of the most important techniques for writing clean conditions is early return: return a value as soon as possible for edge conditions, then handle the main case without excessive nesting.

// ANTI-PATTERN: Deeply nested conditions — hard to read
function hitungBonus(
  karyawan: { aktif: boolean; nilai: number; masa: number } | null
): number {
  if (karyawan !== null) {
    if (karyawan.aktif) {
      if (karyawan.nilai >= 80) {
        if (karyawan.masa >= 2) {
          return karyawan.nilai * 500_000;
        } else {
          return karyawan.nilai * 250_000;
        }
      } else {
        return 0;
      }
    } else {
      return 0;
    }
  } else {
    return 0;
  }
}

// CORRECT: Early return — flat, easy to read, easy to test
function hitungBonus2(
  karyawan: { aktif: boolean; nilai: number; masa: number } | null
): number {
  if (karyawan === null)    return 0; // Guard: no data
  if (!karyawan.aktif)     return 0; // Guard: employee not active
  if (karyawan.nilai < 80) return 0; // Guard: score doesn't qualify

  // Main case — all guards passed
  const multiplier = karyawan.masa >= 2 ? 500_000 : 250_000;
  return karyawan.nilai * multiplier;
}

switch-case and Exhaustive Checks #

switch-case is ideal when you need to check one variable against many discrete values. In TypeScript, switch also performs narrowing — inside each case, TypeScript knows the variable’s value is exactly the value of that case.

type MetodePembayaran = "transfer" | "kartu_kredit" | "gopay" | "ovo";

interface InstruksiPembayaran {
  langkah: string[];
  batasWaktu: number; // in minutes
}

function ambilInstruksi(metode: MetodePembayaran): InstruksiPembayaran {
  switch (metode) {
    case "transfer":
      return {
        langkah: ["Buka aplikasi bank", "Transfer ke rekening tujuan", "Konfirmasi"],
        batasWaktu: 60,
      };

    case "kartu_kredit":
      return {
        langkah: ["Masukkan nomor kartu", "Isi CVV dan tanggal kedaluwarsa", "Konfirmasi OTP"],
        batasWaktu: 10,
      };

    case "gopay":
    case "ovo": // Fall-through — two cases share the same logic
      return {
        langkah: ["Buka aplikasi dompet digital", "Scan QR Code", "Konfirmasi pembayaran"],
        batasWaktu: 5,
      };
  }
  // TypeScript knows the switch is exhaustive — no default needed if all cases are handled
}

Exhaustive Checks with never #

The most powerful switch pattern in TypeScript is ensuring all union possibilities are handled. If you add a new value to the union but forget to handle it in the switch, the compiler reports an error:

type JenisNotifikasi = "email" | "sms" | "push";

function kirimNotifikasi(jenis: JenisNotifikasi, pesan: string): void {
  switch (jenis) {
    case "email":
      console.log(`Kirim email: ${pesan}`);
      break;
    case "sms":
      console.log(`Kirim SMS: ${pesan}`);
      break;
    case "push":
      console.log(`Kirim push notification: ${pesan}`);
      break;
    default: {
      // If all cases are handled, TypeScript knows jenis is of type never here
      // If there's a new type in the union that isn't handled, this line will error
      const _tidakMungkin: never = jenis;
      throw new Error(`Jenis notifikasi tidak dikenal: ${jenis}`);
    }
  }
}

// Now add "whatsapp" to the union without updating the switch
// type JenisNotifikasi = "email" | "sms" | "push" | "whatsapp";
// → TypeScript will error in the default case: Type '"whatsapp"' is not assignable to type 'never'
// This forces you to add a "whatsapp" case — nothing gets missed!

switch vs if-else — When to Choose Which #

flowchart TD
    A{How many values are\\nbeing compared?} --> B[1-2 values]
    A --> C[3+ discrete values]
    A --> D[Ranges / non-equality\\nconditions]

    B --> E[if-else\\nis more concise]
    C --> F{Is the variable's type\\na literal union?}
    D --> G[if-else with\\n>= <= > <]

    F -- Yes --> H[switch-case\\nwith exhaustive check]
    F -- No --> I[if-else or\\nobject lookup]

    style E fill:#51cf66,color:#fff
    style H fill:#339af0,color:#fff
    style G fill:#51cf66,color:#fff
    style I fill:#fcc419,color:#000

Type Guards — Narrowing Types Customly #

A type guard is a function or expression that tells TypeScript how to narrow a type within a conditional context. TypeScript understands several forms of type guards automatically, but you can also write custom type guards.

Built-in Type Guards #

TypeScript understands narrowing from several expression patterns automatically:

function prosesData(data: string | number | boolean | null | undefined): string {
  // typeof guard — for primitive types
  if (typeof data === "string") {
    return data.trim(); // data: string
  }

  if (typeof data === "number") {
    return data.toLocaleString("id-ID"); // data: number
  }

  // Equality — for null/undefined and literals
  if (data === null || data === undefined) {
    return "Tidak ada data"; // data: null | undefined
  }

  // After all the guards above, TypeScript knows data: boolean
  return data ? "Ya" : "Tidak";
}

Custom Type Guards with is #

For union types involving objects, typeof isn’t enough — you need to write a custom type guard function with a return type using the nilai is Tipe syntax:

interface Pengguna {
  tipe: "pengguna";
  nama: string;
  email: string;
}

interface Admin {
  tipe: "admin";
  nama: string;
  levelAkses: number;
}

type Akun = Pengguna | Admin;

// Custom type guard — the "nilai is Admin" return type is the key
function isAdmin(akun: Akun): akun is Admin {
  return akun.tipe === "admin";
}

function tampilkanDasbor(akun: Akun): void {
  if (isAdmin(akun)) {
    // Here: TypeScript knows akun is of type Admin
    console.log(`Admin level ${akun.levelAkses}: ${akun.nama}`);
    // akun.email; // ✗ Error — Admin doesn't have an email property
  } else {
    // Here: TypeScript knows akun is of type Pengguna
    console.log(`Pengguna: ${akun.nama} (${akun.email})`);
    // akun.levelAkses; // ✗ Error — Pengguna doesn't have levelAkses
  }
}

Type Guards for Validating External Data #

Type guards are very useful when validating data coming from outside the system (API responses, user input, JSON parsing):

interface ResponseAPI {
  id: number;
  nama: string;
  email: string;
}

// A type guard that validates the shape of an object from an external source
function isResponseAPI(data: unknown): data is ResponseAPI {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "nama" in data &&
    "email" in data &&
    typeof (data as ResponseAPI).id === "number" &&
    typeof (data as ResponseAPI).nama === "string" &&
    typeof (data as ResponseAPI).email === "string"
  );
}

async function ambilPengguna(id: number): Promise<ResponseAPI | null> {
  const response = await fetch(`/api/pengguna/${id}`);
  const data: unknown = await response.json();

  if (isResponseAPI(data)) {
    // TypeScript knows data is of type ResponseAPI here
    return data;
  }

  console.error("Format response tidak valid:", data);
  return null;
}

Assertion Functions — Type Guards That Throw Errors #

An assertion function is a type guard variant that throws an error if the condition isn’t met — instead of returning a boolean:

// Assertion function — doesn't return a value, but throws on failure
function pastikan<T>(
  nilai: T | null | undefined,
  pesan: string
): asserts nilai is T {
  if (nilai === null || nilai === undefined) {
    throw new Error(`Assertion gagal: ${pesan}`);
  }
}

function prosesKonfigurasi(env: NodeJS.ProcessEnv): void {
  const databaseUrl = env.DATABASE_URL;
  const secretKey = env.SECRET_KEY;

  // After pastikan(), TypeScript knows the values aren't null/undefined
  pastikan(databaseUrl, "DATABASE_URL harus diset di environment");
  pastikan(secretKey, "SECRET_KEY harus diset di environment");

  // Here: databaseUrl and secretKey are of type string (not string | undefined)
  console.log(`Koneksi ke: ${databaseUrl}`);
}

Discriminated Unions — Type-Based Conditions #

A discriminated union is a pattern where every union member has a unique literal property as its “discriminant” — the key that distinguishes one member from another. This enables very clean narrowing without custom type guards.

// Every state has a "status" property as its discriminant
type StatePermintaan =
  | { status: "idle" }
  | { status: "memuat" }
  | { status: "berhasil"; data: string[]; total: number }
  | { status: "gagal"; kodeError: number; pesan: string };

function renderKonten(state: StatePermintaan): string {
  switch (state.status) {
    case "idle":
      return "Tekan tombol untuk memuat data";

    case "memuat":
      return "Sedang memuat...";

    case "berhasil":
      // TypeScript knows state.data and state.total exist here
      return `${state.total} item ditemukan: ${state.data.join(", ")}`;

    case "gagal":
      // TypeScript knows state.kodeError and state.pesan exist here
      return `Error ${state.kodeError}: ${state.pesan}`;
  }
}

// Usage
const stateAwal: StatePermintaan = { status: "idle" };
const stateMemuat: StatePermintaan = { status: "memuat" };
const stateBerhasil: StatePermintaan = {
  status: "berhasil",
  data: ["Produk A", "Produk B"],
  total: 2,
};

console.log(renderKonten(stateBerhasil));
// "2 item ditemukan: Produk A, Produk B"

Object Lookups as a switch Alternative #

For simple conditions that just map one value to another, an object lookup is a more concise and efficient alternative to switch-case:

type KodeHTTP = 200 | 201 | 400 | 401 | 403 | 404 | 500;

// ANTI-PATTERN: switch-case for simple value mapping — verbose
function pesanHTTPSwitch(kode: KodeHTTP): string {
  switch (kode) {
    case 200: return "OK";
    case 201: return "Created";
    case 400: return "Bad Request";
    case 401: return "Unauthorized";
    case 403: return "Forbidden";
    case 404: return "Not Found";
    case 500: return "Internal Server Error";
  }
}

// CORRECT: Object lookup — concise and easy to extend
const PESAN_HTTP: Record<KodeHTTP, string> = {
  200: "OK",
  201: "Created",
  400: "Bad Request",
  401: "Unauthorized",
  403: "Forbidden",
  404: "Not Found",
  500: "Internal Server Error",
};

function pesanHTTP(kode: KodeHTTP): string {
  return PESAN_HTTP[kode];
}

// Object lookups can also store functions (strategy pattern)
type FormatTanggal = "pendek" | "panjang" | "iso";

const formatter: Record<FormatTanggal, (d: Date) => string> = {
  pendek: (d) => d.toLocaleDateString("id-ID"),
  panjang: (d) => d.toLocaleDateString("id-ID", { weekday: "long", year: "numeric", month: "long", day: "numeric" }),
  iso:    (d) => d.toISOString(),
};

function formatTanggal(tanggal: Date, format: FormatTanggal): string {
  return formatter[format](tanggal);
}

console.log(formatTanggal(new Date(), "panjang"));
// "Kamis, 7 Mei 2026"

Conditions with Optional Chaining and Nullish Coalescing #

The combination of ?. and ?? allows writing conditions for nullable values far more concisely than manual if-else:

interface Artikel {
  judul: string;
  penulis?: {
    nama: string;
    verifikasi?: boolean;
  };
  kategori?: string[];
}

const artikel: Artikel = {
  judul: "Belajar TypeScript",
};

// ANTI-PATTERN: Nested if-else for nullable values
let namaPenulis: string;
if (artikel.penulis !== undefined) {
  namaPenulis = artikel.penulis.nama;
} else {
  namaPenulis = "Anonim";
}

let terverifikasi: boolean;
if (artikel.penulis !== undefined && artikel.penulis.verifikasi !== undefined) {
  terverifikasi = artikel.penulis.verifikasi;
} else {
  terverifikasi = false;
}

// CORRECT: Optional chaining + nullish coalescing — concise and expressive
const namaPenulis2  = artikel.penulis?.nama ?? "Anonim";
const terverifikasi2 = artikel.penulis?.verifikasi ?? false;
const kategoriUtama  = artikel.kategori?.[0] ?? "Umum";

console.log(`${namaPenulis2} (${terverifikasi2 ? "✓" : "belum terverifikasi"})`);
// "Anonim (belum terverifikasi)"

Choosing a Conditional Construct #

flowchart TD
    A{What do you want\\nto do?} --> B[Check one\\nboolean condition]
    A --> C[Compare a variable\\nto several values]
    A --> D[Map one value\\nto another value]
    A --> E[Check the type of\\na union type]
    A --> F[Access nullable\\nproperties]

    B --> B1[if-else]
    C --> C1{Many cases?}
    C1 -- Yes, literal union --> C2[switch-case +\\nexhaustive check]
    C1 -- No or ranges --> C3[if-else if]
    D --> D1[Object lookup\\nRecord< K, V >]
    E --> E1{Primitive type\\nor object?}
    E1 -- Primitive --> E2[typeof guard]
    E1 -- Object/class --> E3[instanceof or\\ncustom type guard]
    F --> F1[Optional chaining ?.\\nNullish coalescing ??]

    style B1 fill:#51cf66,color:#fff
    style C2 fill:#339af0,color:#fff
    style C3 fill:#51cf66,color:#fff
    style D1 fill:#fcc419,color:#000
    style E2 fill:#51cf66,color:#fff
    style E3 fill:#339af0,color:#fff
    style F1 fill:#51cf66,color:#fff

Summary #

  • Automatic type narrowing — every conditional construct in TypeScript (if, switch, typeof, instanceof, in, ===) narrows a variable’s type automatically inside the relevant block; take advantage of this to avoid manual type assertions.
  • The early return pattern — return values as soon as possible for edge conditions (null checks, validation), then handle the main case without excessive nesting; this improves readability and reduces cognitive complexity.
  • Exhaustive checks with never in a switch’s default case — if you add a new value to a literal union but forget to handle it in the switch, the compiler immediately reports an error; this is an extremely valuable safety net.
  • Custom type guards (nilai is Tipe) — write explicit type guard functions for union types involving objects; this makes narrowing available throughout the code, not just at one point.
  • Assertion functions (asserts nilai is T) — type guards that throw errors instead of returning booleans; ideal for validating prerequisites at the start of a function.
  • Discriminated unions are an elegant conditional alternative — with a literal discriminant property, TypeScript automatically knows which properties are available in each branch without custom type guards.
  • Object lookups are more concise than switch for simple value mapping — use Record<K, V> for mapping values to values, or Record<K, () => V> for mapping values to functions (strategy pattern).
  • ?. and ?? replace if-else for nullable values — optional chaining for safe access, nullish coalescing for fallbacks; both are far more concise and less prone to typos.

← Previous: Operators   Next: Loops →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact