Kenapa ini penting
Data di dunia nyata jarang hanya punya satu bentuk. Status pesanan bisa "menunggu", "dikirim", atau "selesai". Hasil request bisa sukses (ada data) atau gagal (ada pesan error). ID bisa angka atau string. Union type memungkinkan kamu menulis "nilai ini salah satu dari beberapa kemungkinan", dan narrowing adalah cara TypeScript mengikuti logika if kamu untuk mengetahui kemungkinan mana yang sedang berlaku.
Ini fitur yang membuat TypeScript terasa "pintar". Analoginya seperti petugas penyortir paket: semua paket masuk lewat satu pintu (union), lalu di tiap meja paket diperiksa ("ini dokumen?", "ini barang pecah belah?") dan diarahkan ke penanganan yang tepat (narrowing). Kombinasi keduanya, terutama discriminated union, adalah pola yang akan kamu temui di reducer React, state request data, dan hampir semua aplikasi serius.
Konsep inti
Union type
let id: number | string;
id = 101;
id = "INV-101";
Pada nilai bertipe union, kamu hanya boleh memakai operasi yang berlaku untuk semua anggota. id.toUpperCase() ditolak karena number tidak punya toUpperCase. Kamu harus mempersempit dulu.
Literal type
Literal type adalah nilai spesifik yang dijadikan tipe. Digabung dengan union, hasilnya mirip enum tapi lebih ringan dan tidak menghasilkan kode runtime:
type Ukuran = "S" | "M" | "L" | "XL";
type ArahLampu = "merah" | "kuning" | "hijau";
type Dadu = 1 | 2 | 3 | 4 | 5 | 6;
Editor akan memberi autocomplete pilihan "S", "M", dst., dan salah ketik "xl" langsung ditolak.
as const
as const membuat TypeScript menyimpulkan tipe paling sempit dan readonly. Berguna untuk daftar nilai yang ingin kamu jadikan union:
const UKURAN = ["S", "M", "L", "XL"] as const;
type Ukuran = (typeof UKURAN)[number]; // "S" | "M" | "L" | "XL"
Kelebihannya: daftar nilai (untuk dropdown misalnya) dan tipe-nya berasal dari satu sumber.
Teknik narrowing
| Teknik | Cocok untuk | Contoh |
|---|---|---|
typeof |
Primitif (string, number, boolean, function, ...) |
typeof x === "string" |
| Truthiness | Menyingkirkan null/undefined |
if (user) { ... } |
| Equality | Literal tertentu | if (status === "gagal") |
in |
Object dengan properti berbeda | if ("diskon" in produk) |
instanceof |
Instance class (Date, Error, class buatanmu) |
if (err instanceof Error) |
Array.isArray |
Membedakan array dan bukan array | Array.isArray(input) |
| Type predicate | Logika pemeriksaan buatan sendiri | function isUser(x): x is User |
Hati-hati dengan truthiness: if (jumlah) juga menyingkirkan 0 dan "", padahal keduanya mungkin nilai yang sah.
Discriminated union
Ini pola terpenting di modul ini. Setiap anggota union punya satu properti yang sama (disebut discriminant atau "tag") dengan nilai literal berbeda:
type StatusRequest =
| { status: "memuat" }
| { status: "sukses"; data: string[] }
| { status: "gagal"; pesan: string };
Dengan memeriksa status, TypeScript tahu persis properti apa yang tersedia. Bandingkan dengan pola "semua opsional" { loading: boolean; data?: string[]; error?: string } yang membolehkan keadaan mustahil seperti loading: true sekaligus error terisi.
Exhaustive check dengan never
Kalau semua anggota union sudah ditangani, sisanya bertipe never. Kita bisa memanfaatkan ini agar TypeScript memaksa kita menangani anggota baru:
function pastikanTidakAda(x: never): never {
throw new Error(`Kasus tidak ditangani: ${JSON.stringify(x)}`);
}
Taruh di default dari switch. Hari ini kodenya lolos. Besok, ketika rekan menambahkan { status: "dibatalkan" }, setiap switch yang belum menanganinya langsung merah. Ini seperti alarm "ada pintu yang belum dikunci".
Type predicate
Untuk pemeriksaan yang lebih rumit, buat function yang mengembalikan nilai is Tipe:
type Kucing = { jenis: "kucing"; meong: () => string };
function isKucing(hewan: unknown): hewan is Kucing {
return typeof hewan === "object" && hewan !== null && "jenis" in hewan && hewan.jenis === "kucing";
}
Ingat, TypeScript percaya pada predicate-mu. Kalau logikanya salah, narrowing-nya juga salah.
Contoh kode
// pesanan.ts, discriminated union untuk status pesanan
type Pesanan =
| { status: "menunggu"; id: string; dibuat: Date }
| { status: "dikirim"; id: string; noResi: string; kurir: "JNE" | "SiCepat" | "J&T" }
| { status: "selesai"; id: string; diterimaOleh: string }
| { status: "dibatalkan"; id: string; alasan: string };
function pastikanTidakAda(x: never): never {
throw new Error(`Status tidak dikenal: ${JSON.stringify(x)}`);
}
function labelPesanan(p: Pesanan): string {
switch (p.status) {
case "menunggu":
return `#${p.id} menunggu pembayaran sejak ${p.dibuat.toLocaleDateString("id-ID")}`;
case "dikirim":
return `#${p.id} dikirim via ${p.kurir}, resi ${p.noResi}`; // p.noResi aman di sini
case "selesai":
return `#${p.id} diterima oleh ${p.diterimaOleh}`;
case "dibatalkan":
return `#${p.id} dibatalkan: ${p.alasan}`;
default:
return pastikanTidakAda(p); // p bertipe never di sini
}
}
console.log(labelPesanan({ status: "dikirim", id: "A12", noResi: "JN123", kurir: "JNE" }));
// → #A12 dikirim via JNE, resi JN123
console.log(labelPesanan({ status: "dibatalkan", id: "A13", alasan: "stok habis" }));
// → #A13 dibatalkan: stok habis
// Mengakses properti yang belum pasti ada
function resi(p: Pesanan): string {
return p.noResi; // ❌ error TS2339: Property 'noResi' does not exist on type 'Pesanan'.
}
Kalau suatu hari tim menambah status baru tapi lupa memperbarui switch, inilah yang terjadi:
// exhaustive.ts
type Lampu = "merah" | "kuning" | "hijau" | "mati";
function pastikanTidakAda(x: never): never {
throw new Error(`Tidak ditangani: ${x}`);
}
function aksi(l: Lampu): string {
switch (l) {
case "merah":
return "Berhenti";
case "kuning":
return "Hati-hati";
case "hijau":
return "Jalan";
default:
return pastikanTidakAda(l); // ❌ error TS2345: Argument of type '"mati"' is not assignable to parameter of type 'never'.
}
}
console.log(aksi("merah"));
TypeScript menunjuk tepat ke tempat yang belum menangani "mati". Pesannya jelas: sisa tipe "mati" tidak bisa diberikan ke parameter never, artinya kasus itu belum ditangani. Tambahkan case "mati": return "Lampu rusak, ikuti petugas"; dan error hilang.
Teknik narrowing lainnya dalam satu file:
// narrowing.ts
function formatId(id: number | string): string {
if (typeof id === "number") return id.toString().padStart(6, "0"); // id: number
return id.toUpperCase(); // id: string
}
console.log(formatId(42), formatId("inv-7")); // → 000042 INV-7
function pesanError(err: unknown): string {
if (err instanceof Error) return err.message; // err: Error
if (typeof err === "string") return err; // err: string
return "Terjadi kesalahan yang tidak diketahui";
}
console.log(pesanError(new Error("Koneksi putus"))); // → Koneksi putus
type Admin = { peran: "admin"; izin: string[] };
type Tamu = { peran: "tamu" };
function bolehHapus(u: Admin | Tamu): boolean {
return "izin" in u && u.izin.includes("hapus"); // narrowing dengan in
}
console.log(bolehHapus({ peran: "admin", izin: ["baca", "hapus"] })); // → true
// Type predicate untuk menyaring null dari array
const hasilCari: (string | null)[] = ["Sari", null, "Budi", null];
const namaValid = hasilCari.filter((n): n is string => n !== null); // string[]
console.log(namaValid.map((n) => n.toUpperCase())); // → [ 'SARI', 'BUDI' ]
Kesalahan umum
- Memakai banyak boolean opsional untuk mewakili status.
{ loading?: boolean; error?: string; data?: T }membolehkan kombinasi mustahil. Pakai discriminated union dengan satu propertistatus. - Narrowing dengan truthiness pada angka atau string.
if (stok)menganggap0sama dengan "tidak ada". Bandingkan secara eksplisit:if (stok !== undefined). - Lupa
defaultdengan exhaustive check. Tanpa itu, menambah anggota union baru tidak memicu error diswitchyang lupa diperbarui. - Menulis type predicate yang salah.
x is Userdipercaya mentah-mentah. Kalau pemeriksaannya asal, bug masuk diam-diam. Untuk data kompleks, pakai Zod. - Memakai
enumhanya untuk daftar string. Union literal ("S" | "M") lebih ringan, tidak menghasilkan kode runtime, dan didukung type stripping Node sertaerasableSyntaxOnly.
Latihan
- Mudah, Buat
type Hari = "Senin" | "Selasa" | ... | "Minggu"dan functionakhirPekan(h: Hari): boolean. - Sedang, Buat discriminated union
Bentukuntuklingkaran(jari),persegi(sisi), dansegitiga(alas, tinggi). Tulisluas(b: Bentuk): numberdenganswitchdan exhaustive check. - Menantang, Buat
type HasilBayar = { ok: true; idTransaksi: string } | { ok: false; kode: "SALDO_KURANG" | "TIMEOUT"; pesan: string }. Tulistampilkan(h: HasilBayar): stringyang memberi pesan berbeda untuk setiap kode error, lalu gunakan type predicateisGagal.
Lihat solusi
// 1. Mudah
type Hari = "Senin" | "Selasa" | "Rabu" | "Kamis" | "Jumat" | "Sabtu" | "Minggu";
const akhirPekan = (h: Hari): boolean => h === "Sabtu" || h === "Minggu";
console.log(akhirPekan("Sabtu"), akhirPekan("Rabu")); // → true false
// 2. Sedang
type Bentuk =
| { jenis: "lingkaran"; jari: number }
| { jenis: "persegi"; sisi: number }
| { jenis: "segitiga"; alas: number; tinggi: number };
function pastikanTidakAda(x: never): never {
throw new Error("Bentuk tidak dikenal: " + JSON.stringify(x));
}
function luas(b: Bentuk): number {
switch (b.jenis) {
case "lingkaran": return Math.PI * b.jari ** 2;
case "persegi": return b.sisi ** 2;
case "segitiga": return (b.alas * b.tinggi) / 2;
default: return pastikanTidakAda(b);
}
}
console.log(luas({ jenis: "segitiga", alas: 10, tinggi: 6 })); // → 30
// 3. Menantang
type HasilBayar =
| { ok: true; idTransaksi: string }
| { ok: false; kode: "SALDO_KURANG" | "TIMEOUT"; pesan: string };
function isGagal(h: HasilBayar): h is Extract<HasilBayar, { ok: false }> {
return !h.ok;
}
function tampilkan(h: HasilBayar): string {
if (isGagal(h)) {
return h.kode === "SALDO_KURANG"
? `Saldo tidak cukup: ${h.pesan}`
: "Server lambat merespons, coba lagi sebentar lagi.";
}
return `Pembayaran berhasil, ID ${h.idTransaksi}`;
}
console.log(tampilkan({ ok: true, idTransaksi: "TRX-01" })); // → Pembayaran berhasil, ID TRX-01
console.log(tampilkan({ ok: false, kode: "SALDO_KURANG", pesan: "kurang Rp5.000" }));
// → Saldo tidak cukup: kurang Rp5.000
Extract<Union, Bentuk> adalah utility type yang mengambil anggota union yang cocok; kamu akan mempelajarinya di modul Utility Types. Di sini kita juga bisa memakai if (!h.ok) saja, karena ok sudah menjadi discriminant.
Ringkasan
- Union
A | Bberarti salah satu dari beberapa tipe; hanya operasi yang berlaku untuk semua anggota yang diizinkan sebelum narrowing. - Literal type (
"S" | "M") danas constmenggantikan enum dengan cara yang ringan. - Narrowing memakai
typeof, truthiness, equality,in,instanceof,Array.isArray, dan type predicate. - Discriminated union dengan properti tag (
status,jenis) adalah pola utama untuk memodelkan keadaan. - Exhaustive check dengan
nevermemastikan setiap anggota union baru ditangani.