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:#000Type 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:#fffSummary #
- 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
neverin a switch’sdefaultcase — 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, orRecord<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.