kodingup.camp

Beranda/TypeScript Mastery

Union, Literal & Narrowing

Modul 6 dari 124 menit bacaUnions and Narrowing

Isi modul

    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

    1. Memakai banyak boolean opsional untuk mewakili status. { loading?: boolean; error?: string; data?: T } membolehkan kombinasi mustahil. Pakai discriminated union dengan satu properti status.
    2. Narrowing dengan truthiness pada angka atau string. if (stok) menganggap 0 sama dengan "tidak ada". Bandingkan secara eksplisit: if (stok !== undefined).
    3. Lupa default dengan exhaustive check. Tanpa itu, menambah anggota union baru tidak memicu error di switch yang lupa diperbarui.
    4. Menulis type predicate yang salah. x is User dipercaya mentah-mentah. Kalau pemeriksaannya asal, bug masuk diam-diam. Untuk data kompleks, pakai Zod.
    5. Memakai enum hanya untuk daftar string. Union literal ("S" | "M") lebih ringan, tidak menghasilkan kode runtime, dan didukung type stripping Node serta erasableSyntaxOnly.

    Latihan

    1. Mudah, Buat type Hari = "Senin" | "Selasa" | ... | "Minggu" dan function akhirPekan(h: Hari): boolean.
    2. Sedang, Buat discriminated union Bentuk untuk lingkaran (jari), persegi (sisi), dan segitiga (alas, tinggi). Tulis luas(b: Bentuk): number dengan switch dan exhaustive check.
    3. Menantang, Buat type HasilBayar = { ok: true; idTransaksi: string } | { ok: false; kode: "SALDO_KURANG" | "TIMEOUT"; pesan: string }. Tulis tampilkan(h: HasilBayar): string yang memberi pesan berbeda untuk setiap kode error, lalu gunakan type predicate isGagal.
    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

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    TypeScript Exhaustive Switch: How discriminated unions make your job easier!Andrew Burgess · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.