Langsung ke konten
KamusNgoding
Menengah Typescript 6 menit baca

Memahami Type Narrowing dan Type Guards di TypeScript

#typescript #type guards #narrowing #intermediate

Memahami Type Narrowing dan Type Guards di TypeScript

Pendahuluan

Salah satu kekuatan terbesar TypeScript dibanding JavaScript biasa adalah kemampuannya untuk mendeteksi kesalahan tipe sebelum kode dijalankan. Namun, ada situasi di mana TypeScript perlu sedikit “petunjuk” tambahan dari kita sebagai developer untuk benar-benar memahami tipe data yang sedang diproses.

Di sinilah Type Narrowing dan Type Guards berperan. Kedua konsep ini memungkinkan TypeScript untuk mempersempit tipe data yang ambigu menjadi tipe yang lebih spesifik, sehingga kita bisa mengakses properti dan method yang tepat tanpa error kompilasi.

Jika kamu sudah familiar dengan konsep dasar seperti interface dan type alias di TypeScript, artikel ini akan membawa pemahamanmu ke level berikutnya dengan teknik-teknik yang sering dipakai di proyek nyata.


Apa itu Type Narrowing?

Type Narrowing adalah proses di mana TypeScript mempersempit (narrows) tipe data dari yang lebih umum ke yang lebih spesifik, berdasarkan kondisi atau pengecekan yang kita tulis dalam kode.

Bayangkan kamu punya variabel bertipe string | number. TypeScript tidak tahu secara langsung mana yang aktif di runtime, jadi ia memperlakukan keduanya sebagai kemungkinan. Dengan type narrowing, kita memberi tahu TypeScript: “Di blok kode ini, variabel tersebut pasti bertipe string.”

function cetakPanjang(nilai: string | number): void {
  // Narrowing: cek tipe data sebelum memakai method khusus string/number
  if (typeof nilai === "string") {
    // Di blok ini, TypeScript tahu bahwa 'nilai' bertipe string
    console.log(nilai.toUpperCase());
  } else {
    // Di blok ini, TypeScript tahu bahwa 'nilai' bertipe number
    console.log(nilai.toFixed(2));
  }
}

// Contoh pemanggilan fungsi
cetakPanjang("halo");
cetakPanjang(3.14159);

/*
# Output yang diharapkan:
# > HALO
# > 3.14
*/

Perhatikan bagaimana TypeScript secara otomatis memahami tipe yang tepat di dalam setiap blok if/else.


Mengapa Type Narrowing Penting?

Tanpa type narrowing, kamu terpaksa menggunakan banyak type assertion (as) yang berbahaya, atau membiarkan TypeScript memberikan error yang mengganggu. Ini penting karena:

  1. Keamanan runtime — Menghindari error seperti TypeError: nilai.toUpperCase is not a function
  2. Intellisense yang akurat — IDE akan menampilkan autocomplete yang benar sesuai tipe yang sudah dipersempit
  3. Kode lebih ekspresif — Logika bisnis lebih mudah dibaca tanpa perlu banyak komentar penjelasan

Misalnya, jika kamu ingin membangun layanan seperti Tokopedia yang memproses berbagai jenis respons API (sukses, error, pending), type narrowing memastikan setiap jenis respons ditangani dengan cara yang tepat.


Mengenal Type Guards

Type Guard adalah ekspresi atau fungsi yang melakukan pengecekan runtime dan memberitahu TypeScript tentang tipe yang lebih spesifik di scope tertentu.

Ada dua jenis type guard utama:

  1. Type Guards Bawaan — Menggunakan operator JavaScript seperti typeof, instanceof, dan in
  2. Type Guards Kustom (User-Defined) — Fungsi yang kita buat sendiri dengan return type nilai is Tipe

Keduanya sama pentingnya: type guards bawaan cocok untuk kasus sederhana, sedangkan type guards kustom dibutuhkan saat struktur data semakin kompleks.


Type Guards Bawaan di TypeScript (typeof, instanceof, in)

typeof

Operator typeof bekerja untuk tipe primitif: string, number, boolean, bigint, symbol, undefined, dan function.

type InputProses = string | number | boolean;

function proses(input: InputProses): string {
  // Type guard: TypeScript mempersempit tipe berdasarkan hasil typeof
  if (typeof input === "string") {
    return `Teks: ${input.trim()}`;
  }

  // Di blok ini, input sudah dikenali sebagai number
  if (typeof input === "number") {
    return `Angka: ${input.toFixed(2)}`;
  }

  // Jika bukan string atau number, input pasti bertipe boolean
  return `Boolean: ${input ? "benar" : "salah"}`;
}

console.log(proses("  Halo Dunia  "));
console.log(proses(42.567));
console.log(proses(true));

/*
# Output yang diharapkan:
# > Teks: Halo Dunia
# > Angka: 42.57
# > Boolean: benar
*/

instanceof

Operator instanceof cocok untuk objek yang dibuat dari class:

class Kucing {
  mengeong(): string {
    return "Meow!";
  }
}

class Anjing {
  menggonggong(): string {
    return "Woof!";
  }
}

// Parameter menerima objek Kucing atau Anjing menggunakan union type
function suaraHewan(hewan: Kucing | Anjing): string {
  // instanceof mempersempit tipe menjadi Kucing
  if (hewan instanceof Kucing) {
    return hewan.mengeong();
  }

  // Jika bukan Kucing, TypeScript tahu bahwa hewan adalah Anjing
  return hewan.menggonggong();
}

console.log(suaraHewan(new Kucing()));
console.log(suaraHewan(new Anjing()));

/*
# Output yang diharapkan:
# > Meow!
# > Woof!
*/

in

Operator in mengecek apakah sebuah properti ada dalam objek — sangat berguna untuk membedakan interface:

interface Admin {
  role: "admin";
  aksesAdmin(): void;
}

interface Pengguna {
  role: "user";
  profil: string;
}

type Akun = Admin | Pengguna;

function tampilkanInfo(akun: Akun): void {
  // Mengecek nilai role untuk membedakan tipe Admin dan Pengguna
  if (akun.role === "admin") {
    akun.aksesAdmin(); // TypeScript tahu akun bertipe Admin di blok ini
    console.log("Ini adalah akun Admin");
  } else {
    console.log(`Profil pengguna: ${akun.profil}`); // TypeScript tahu akun bertipe Pengguna
  }
}

const admin: Admin = {
  role: "admin",
  aksesAdmin() {
    console.log("Akses admin berhasil");
  },
};

const pengguna: Pengguna = {
  role: "user",
  profil: "Budi, Developer Frontend",
};

tampilkanInfo(admin);
tampilkanInfo(pengguna);

/*
# Output yang diharapkan:
# > Akses admin berhasil
# > Ini adalah akun Admin
# > Profil pengguna: Budi, Developer Frontend
*/

Equality Narrowing

TypeScript juga bisa mempersempit tipe menggunakan pengecekan kesetaraan (===, !==, ==, !=):

function periksaNilai(x: string | number, y: string | boolean): void {
  // Mengecek apakah nilai dan tipe x sama persis dengan y
  if (x === y) {
    // Di blok ini, TypeScript tahu x dan y pasti bertipe string
    // karena hanya string yang ada di kedua union type
    console.log(x.toUpperCase());
    console.log(y.toUpperCase());
  }
}

periksaNilai("typescript", "typescript");

/*
# Output yang diharapkan:
# > TYPESCRIPT
# > TYPESCRIPT
*/

Ini juga berguna untuk pengecekan nilai literal:

type Status = "aktif" | "nonaktif" | "pending"; // Tipe union: hanya tiga nilai ini yang valid

function periksaStatus(status: Status): void {
  // TypeScript mempersempit tipe berdasarkan kondisi berikut
  if (status === "aktif") {
    console.log("Akun sedang aktif");
  } else if (status === "nonaktif") {
    console.log("Akun dinonaktifkan");
  } else {
    // Di sini TypeScript tahu status pasti bernilai "pending"
    console.log("Menunggu verifikasi");
  }
}

periksaStatus("aktif");
periksaStatus("pending");

/*
# Output yang diharapkan:
# > Akun sedang aktif
# > Menunggu verifikasi
*/

Truthiness Narrowing

JavaScript memiliki konsep truthy dan falsy. TypeScript memanfaatkan ini untuk mempersempit tipe, terutama untuk menghilangkan null dan undefined:

function tampilkanPesan(pesan: string | null | undefined): void {
  // Cek eksplisit agar string kosong ("") tidak dianggap sama dengan null/undefined
  if (pesan !== null && pesan !== undefined && pesan !== "") {
    // Di blok ini, TypeScript tahu bahwa 'pesan' bertipe string
    console.log(pesan.toUpperCase());
  } else {
    console.log("Tidak ada pesan");
  }
}

function prosesItem(items: string[] | null): void {
  // Optional chaining membantu mengecek array tanpa error saat nilainya null
  if (items?.length) {
    // Di blok ini, TypeScript tahu bahwa 'items' adalah string[]
    items.forEach((item, index) => {
      console.log(`${index + 1}. ${item}`);
    });
  } else {
    console.log("Daftar item kosong");
  }
}

tampilkanPesan("Halo TypeScript");
tampilkanPesan(null);

prosesItem(["Buku", "Pulpen"]);
prosesItem([]);

/*
# Output yang diharapkan:
# > HALO TYPESCRIPT
# > Tidak ada pesan
# > 1. Buku
# > 2. Pulpen
# > Daftar item kosong
*/

Membuat Type Guard Kustom (User-Defined Type Guards) dengan is

Terkadang, pengecekan bawaan tidak cukup untuk kasus yang kompleks — misalnya saat memvalidasi data dari API eksternal yang belum diketahui strukturnya. Di sinilah user-defined type guard berperan.

User-defined type guard adalah fungsi biasa dengan return type khusus berbentuk parameter is Tipe. Ketika fungsi ini mengembalikan true, TypeScript secara otomatis mempersempit tipe parameter tersebut di blok yang memanggil fungsi ini.

interface Produk {
  id: number;
  nama: string;
  harga: number;
}

// User-defined type guard: return type "data is Produk" adalah kuncinya.
// TypeScript akan mempersempit tipe 'data' menjadi Produk saat fungsi ini mengembalikan true.
function adalahProduk(data: unknown): data is Produk {
  // Pastikan data adalah object dan bukan null sebelum memeriksa propertinya
  if (typeof data !== "object" || data === null) {
    return false;
  }

  const produk = data as Partial<Produk>;

  // Validasi tipe setiap properti agar aman digunakan sebagai Produk
  return (
    typeof produk.id === "number" &&
    typeof produk.nama === "string" &&
    typeof produk.harga === "number"
  );
}

function formatRupiah(nilai: number): string {
  // Format angka ke mata uang Rupiah dengan locale Indonesia
  return new Intl.NumberFormat("id-ID", {
    style: "currency",
    currency: "IDR",
    maximumFractionDigits: 0,
  }).format(nilai);
}

function prosesData(data: unknown): void {
  if (adalahProduk(data)) {
    // Di dalam blok ini, TypeScript tahu bahwa data bertipe Produk
    console.log(`Produk: ${data.nama}, Harga: ${formatRupiah(data.harga)}`);
    return;
  }

  console.log("Data bukan produk yang valid");
}

prosesData({ id: 1, nama: "Laptop", harga: 15000000 });
prosesData({ id: 2, nama: "Mouse", harga: "250000" }); // harga bertipe string, bukan number

/*
# Output yang diharapkan:
# > Produk: Laptop, Harga: Rp15.000.000
# > Data bukan produk yang valid
*/

Perbedaan penting user-defined type guard dengan type assertion (as):

  • as — memaksa TypeScript percaya tanpa pengecekan apapun, berbahaya di runtime
  • User-defined type guard — melakukan pengecekan nyata, TypeScript hanya mempersempit tipe setelah validasi berhasil

Pola ini mirip dengan Implementasi Facade Pattern di Proyek Nyata, di mana kita menyembunyikan kompleksitas validasi di balik satu fungsi yang bersih.


Discriminated Unions (Tagged Unions)

Discriminated Union adalah teknik paling elegan dalam TypeScript untuk menangani berbagai jenis objek. Caranya adalah dengan menambahkan satu properti diskriminator (biasanya type atau kind) yang nilainya unik untuk setiap anggota union:

// Mendefinisikan discriminated union untuk respons API.
// Properti "status" menjadi pembeda utama untuk setiap bentuk respons.
type ResponsAPI =
  | { status: "sukses"; data: string[]; total: number }
  | { status: "error"; pesan: string; kode: number }
  | { status: "loading" };

function tanganiRespons(respons: ResponsAPI): void {
  switch (respons.status) {
    case "sukses":
      // Di blok ini, TypeScript tahu respons memiliki data dan total
      console.log(`Berhasil! Total: ${respons.total} item`);
      respons.data.forEach((item) => console.log(`- ${item}`));
      break;

    case "error":
      // Di blok ini, TypeScript tahu respons memiliki pesan dan kode
      console.error(`Error ${respons.kode}: ${respons.pesan}`);
      break;

    case "loading":
      // Di blok ini, TypeScript tahu respons hanya berisi status "loading"
      console.log("Sedang memuat...");
      break;

    default:
      // Memastikan semua kemungkinan status sudah ditangani
      const _exhaustiveCheck: never = respons;
      return _exhaustiveCheck;
  }
}

const responsBerhasil: ResponsAPI = {
  status: "sukses",
  data: ["TypeScript", "Discriminated Union"],
  total: 2,
};

tanganiRespons(responsBerhasil);

/*
# Output yang diharapkan:
# > Berhasil! Total: 2 item
# > - TypeScript
# > - Discriminated Union
*/

Properti status di sini adalah diskriminator. TypeScript menggunakannya untuk secara otomatis mempersempit tipe di setiap cabang switch. Pola ini juga sangat cocok dipadukan dengan Panduan Lengkap State Management Lanjutan di React dengan Zustand untuk mengelola state aplikasi yang kompleks.


Contoh Kasus Nyata: Memproses Bentuk Geometris

Mari kita gabungkan semua konsep di atas dalam satu contoh yang komprehensif — sebuah sistem kalkulasi luas berbagai bentuk geometris:

// Mendefinisikan discriminated union untuk beberapa jenis bentuk.
// Properti "jenis" menjadi pembeda agar TypeScript tahu properti mana yang tersedia.
type Bentuk =
  | { jenis: "lingkaran"; jariJari: number }
  | { jenis: "persegi"; sisi: number }
  | { jenis: "persegi_panjang"; lebar: number; tinggi: number }
  | { jenis: "segitiga"; alas: number; tinggi: number };

// User-defined type guard: memastikan nilai angka valid sebagai ukuran bentuk
function adalahUkuranPositif(nilai: unknown): nilai is number {
  return typeof nilai === "number" && Number.isFinite(nilai) && nilai > 0;
}

// Menghitung luas berdasarkan nilai "jenis".
// Di setiap case, TypeScript otomatis mempersempit tipe bentuk.
function hitungLuas(bentuk: Bentuk): number {
  switch (bentuk.jenis) {
    case "lingkaran":
      return Math.PI * bentuk.jariJari ** 2;

    case "persegi":
      return bentuk.sisi ** 2;

    case "persegi_panjang":
      return bentuk.lebar * bentuk.tinggi;

    case "segitiga":
      return 0.5 * bentuk.alas * bentuk.tinggi;

    default: {
      // Exhaustive check: jika ada jenis baru yang belum ditangani,
      // TypeScript akan memberi error di baris ini
      const bentukTidakTertangani: never = bentuk;
      throw new Error(`Bentuk tidak dikenal: ${bentukTidakTertangani}`);
    }
  }
}

// User-defined type guard untuk memvalidasi data yang tipenya belum pasti, misalnya dari API
function adalahBentukValid(input: unknown): input is Bentuk {
  if (typeof input !== "object" || input === null) return false;

  const data = input as Record<string, unknown>;

  switch (data.jenis) {
    case "lingkaran":
      return adalahUkuranPositif(data.jariJari);

    case "persegi":
      return adalahUkuranPositif(data.sisi);

    case "persegi_panjang":
      return adalahUkuranPositif(data.lebar) && adalahUkuranPositif(data.tinggi);

    case "segitiga":
      return adalahUkuranPositif(data.alas) && adalahUkuranPositif(data.tinggi);

    default:
      return false;
  }
}

// Contoh data yang sudah memiliki tipe Bentuk[]
const daftarBentuk: Bentuk[] = [
  { jenis: "lingkaran", jariJari: 7 },
  { jenis: "persegi", sisi: 5 },
  { jenis: "persegi_panjang", lebar: 10, tinggi: 4 },
  { jenis: "segitiga", alas: 8, tinggi: 6 },
];

for (const bentuk of daftarBentuk) {
  const luas = hitungLuas(bentuk);
  console.log(`Luas ${bentuk.jenis}: ${luas.toFixed(2)}`);
}

// Contoh data eksternal yang bertipe unknown dan perlu divalidasi lebih dulu
const dataEksternal: unknown = { jenis: "persegi", sisi: 12 };

if (adalahBentukValid(dataEksternal)) {
  console.log(`Luas dari data eksternal: ${hitungLuas(dataEksternal).toFixed(2)}`);
} else {
  console.log("Data eksternal bukan bentuk yang valid");
}

/*
# Output yang diharapkan:
# > Luas lingkaran: 153.94
# > Luas persegi: 25.00
# > Luas persegi_panjang: 40.00
# > Luas segitiga: 24.00
# > Luas dari data eksternal: 144.00
*/

Contoh ini menunjukkan bagaimana type narrowing, discriminated unions, dan user-defined type guard bekerja bersama untuk menciptakan kode yang aman, ekspresif, dan bebas dari error tipe.


Pertanyaan yang Sering Diajukan

Apa perbedaan antara type assertion (as) dan type guard?

Type assertion (as) secara paksa memberitahu TypeScript untuk memperlakukan sebuah nilai sebagai tipe tertentu tanpa pengecekan apapun — ini berbahaya karena bisa menyebabkan error runtime jika asumsinya salah. Sebaliknya, type guard melakukan pengecekan nyata di runtime sebelum TypeScript mempersempit tipenya, sehingga jauh lebih aman dan direkomendasikan untuk kode produksi.

Kapan sebaiknya menggunakan typeof vs instanceof vs type guard kustom?

Gunakan typeof untuk tipe primitif seperti string, number, boolean, dan undefined. Gunakan instanceof ketika bekerja dengan objek yang dibuat dari class. Untuk interface dan objek literal biasa, gunakan operator in atau buat user-defined type guard karena instanceof tidak bisa digunakan untuk interface — interface tidak ada di runtime JavaScript dan hanya eksis saat kompilasi.

Bagaimana cara kerja exhaustive check dengan never?

Saat semua kemungkinan union sudah ditangani di blok switch atau if-else, TypeScript akan menyimpulkan tipe variabel sebagai never di cabang yang tidak mungkin tercapai. Dengan menetapkan nilai tersebut ke variabel bertipe never, TypeScript akan mengeluarkan error kompilasi jika di masa depan kamu menambahkan anggota baru ke union tanpa menanganinya — ini adalah cara yang elegan untuk memastikan kode selalu lengkap dan tidak ada kasus yang terlewat.

Apakah user-defined type guard mempengaruhi performa aplikasi?

Ya, sedikit, karena user-defined type guard adalah fungsi yang dijalankan di runtime. Namun dampaknya sangat kecil dalam praktik sehari-hari. Keuntungan keamanan tipe yang didapat jauh lebih berharga daripada overhead performa yang minimal, terutama saat memvalidasi data dari API eksternal atau input pengguna yang belum terpercaya.

Bisakah user-defined type guard dikombinasikan dengan generic?

Bisa! User-defined type guard bisa dikombinasikan dengan generic untuk membuat validator yang lebih fleksibel. Misalnya, function adalahArray<T>(val: unknown, cek: (item: unknown) => item is T): val is T[] memungkinkan pengecekan array dari tipe apapun secara generik. Meski begitu, implementasinya tetap perlu pengecekan runtime yang sesuai karena generic dihapus saat kompilasi.


Kesimpulan

Type narrowing dan type guards adalah fondasi dari penulisan TypeScript yang aman dan ekspresif. Dengan menguasai teknik-teknik ini — mulai dari typeof, instanceof, in, equality narrowing, truthiness narrowing, user-defined type guard, hingga discriminated unions — kamu bisa menulis kode yang tidak hanya bebas bug tipe, tetapi juga lebih mudah dibaca dan di-maintain oleh seluruh tim.

Ingat prinsip utamanya: alih-alih memaksa TypeScript percaya pada kita dengan as, lebih baik kita membuktikan tipe yang benar melalui pengecekan yang nyata. Pola discriminated union khususnya sangat direkomendasikan untuk proyek skala besar yang memiliki banyak variasi state atau jenis data.

Selamat belajar dan terus berlatih! Jika ada pertanyaan atau kamu ingin mengeksplorasi topik TypeScript lanjutan lainnya, jangan ragu untuk menjelajahi artikel-artikel relevan lainnya di KamusNgoding — kamu pasti bisa menguasainya!

Artikel Terkait