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.

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.
The typeof operator is the first narrow the compiler recognizes:
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.
A direct check removes empty values:
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 comparisons also narrow:
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.
To tell object shapes apart, use the in operator:
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.
Narrowing against class instances uses instanceof:
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.
When a check is too complex for the compiler, you can write your own guard function:
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.
The most powerful pattern: a union of objects sharing the same discriminating field:
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.
To make sure every possibility is handled, mark the default with never:
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.
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:
typeof for primitives, truthiness for empty values.in distinguishes object shapes.instanceof narrows class instances; type predicates use is.switch safe per branch.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.