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:
- Keamanan runtime — Menghindari error seperti
TypeError: nilai.toUpperCase is not a function - Intellisense yang akurat — IDE akan menampilkan autocomplete yang benar sesuai tipe yang sudah dipersempit
- 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:
- Type Guards Bawaan — Menggunakan operator JavaScript seperti
typeof,instanceof, danin - 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!