Typescript Narrowing Patterns Discriminated Unions Exhaustive Checks Runtime Validation Basics
Definisi
TypeScript memberi tipe statis saat compile time, tapi program tetap berjalan di runtime dengan data yang bisa datang dari API, form, storage, event, atau third-party library. Karena itu, satu tipe yang terlihat aman di editor belum tentu benar saat aplikasi benar-benar jalan.
Topik ini membahas empat hal yang paling sering dipakai untuk menjembatani compile time dan runtime:
- narrowing patterns: cara mempersempit union atau
unknownmenjadi tipe yang lebih spesifik. - discriminated unions: union object yang punya field pembeda stabil, misalnya
kindataustatus. - exhaustive checks: memastikan semua cabang union ditangani.
- runtime validation: memeriksa data masuk sebelum dianggap valid oleh program.
Kalau dipakai bareng, keempatnya membuat kode TypeScript lebih aman, lebih mudah dibaca, dan lebih tahan terhadap data yang tidak sesuai kontrak.
Kenapa ini penting
Masalah paling umum di TypeScript bukan kurang tipe, melainkan terlalu percaya pada tipe.
Contoh klasik:
- response API diketik sebagai
User, padahal server mengirim payload yang salah; localStoragedisimpan sebagai JSON, lalu dibaca ulang tanpa validasi;- input dari form atau
URLSearchParamsdianggap sudah benar; - event dari library eksternal punya shape yang tidak sesuai asumsi.
TypeScript tidak memeriksa data saat runtime. Dia hanya memeriksa apakah kode kamu konsisten dengan tipe yang kamu tulis. Jadi, kalau sumber data di luar kontrolmu, kamu tetap perlu validasi runtime.
Alur dasar narrowing
Narrowing adalah proses mempersempit tipe dari bentuk yang lebih luas ke bentuk yang lebih spesifik berdasarkan kondisi runtime.
Narrowing biasanya terjadi lewat:
- pengecekan
typeof - pengecekan
instanceof - pengecekan
in - equality check terhadap discriminator
- custom type guard
- assertion function
- control flow analysis dari
if,switch, danreturn
Type guards
Type guard adalah pengecekan runtime yang juga dipahami TypeScript untuk mempersempit tipe.
1. typeof
Pakai untuk tipe primitif.
function formatInput(value: string | number) {
if (typeof value === "string") {
return value.trim().toLowerCase();
}
return value.toFixed(2);
}Catatan:
typeof nulladalah"object", jadi jangan dipakai sendirian untuk object validation.typeofberguna untuk membedakanstring,number,boolean,bigint,symbol,function, danundefined.
2. instanceof
Pakai untuk class instance atau built-in object tertentu.
function toMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}Catatan:
instanceofbergantung pada prototype chain.- Untuk data dari JSON,
instanceofbiasanya tidak membantu karena hasil parse bukan instance class.
3. in
Pakai untuk mengecek apakah sebuah properti ada di object.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(pet: Cat | Dog) {
if ("meow" in pet) {
pet.meow();
} else {
pet.bark();
}
}Catatan:
inberguna untuk union object yang berbeda shape.- Hati-hati kalau properti bisa ada di lebih dari satu anggota union.
4. Custom type guard
Custom type guard adalah function yang return type-nya memakai predikat value is T.
type User = {
id: string;
name: string;
};
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return typeof candidate.id === "string" && typeof candidate.name === "string";
}
function handleInput(value: unknown) {
if (isUser(value)) {
return value.name.toUpperCase();
}
return "Invalid user";
}Custom guard bagus kalau:
- cek yang dibutuhkan berulang,
- data masuk dari sumber tidak tepercaya,
- kamu ingin narrowing yang reusable.
5. Assertion function
Assertion function cocok kalau kamu ingin melempar error saat data tidak valid.
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error("Invalid user payload");
}
}Bedanya dengan type guard biasa:
- type guard: dipakai di kondisi
if. - assertion function: kalau lolos, TypeScript menganggap tipe sudah valid setelah pemanggilan fungsi.
Discriminated unions
Discriminated union adalah union object yang punya field pembeda stabil, misalnya kind, type, status, atau state.
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
type State = LoadingState | SuccessState | ErrorState;Field status adalah discriminator. Karena nilainya literal dan konsisten, TypeScript bisa mempersempit tipe secara akurat.
Contoh pemakaian
function renderState(state: State) {
if (state.status === "loading") {
return "Loading...";
}
if (state.status === "success") {
return state.data.join(", ");
}
return state.message;
}Kenapa discriminated union lebih enak
- cabang logic jadi jelas,
- properti khusus tiap variant aman diakses setelah narrowing,
- refactor lebih mudah karena field pembeda jadi source of truth.
Pola yang sering dipakai
statusuntuk state requestkinduntuk event atau commandtypeuntuk actionstepuntuk flow multi-langkah
Jebakan umum
- discriminator tidak stabil atau tidak literal,
- object variant terlalu mirip tanpa pembeda jelas,
- field pembeda ditandai optional, sehingga narrowing jadi lebih lemah,
- satu union dipakai untuk terlalu banyak tanggung jawab.
Kalau union mulai terasa besar, pecah berdasarkan domain atau pisahkan state machine yang lebih spesifik.
Exhaustive checks
Exhaustive check memastikan semua variant union ditangani. Ini penting karena kalau ada variant baru ditambahkan nanti, compiler harus membantu menemukan cabang yang belum diperbarui.
switch exhaustive dengan never
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function renderStateExhaustive(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.join(", ");
case "error":
return state.message;
default:
return assertNever(state);
}
}Kalau ada variant baru ditambahkan ke State, default akan mulai error karena state tidak lagi bertipe never.
Kenapa never penting
never menandakan cabang yang secara logika tidak mungkin terjadi. Jadi saat compiler masih melihat kemungkinan lain, itu artinya handling kamu belum lengkap.
Alternatif selain switch
Kalau tidak memakai switch, kamu masih bisa membuat exhaustive check dengan chain if/else dan assertNever di akhir. Prinsipnya sama: semua branch harus habis.
Runtime validation basics
Runtime validation dipakai untuk memastikan data dari luar benar-benar sesuai shape yang diharapkan sebelum dipakai sebagai tipe domain.
Kapan wajib validasi runtime
- response API
- payload webhook
- data dari
localStorageatausessionStorage - query string dan route params
- form input yang dikirim dari browser
- data file atau import eksternal
Alur validasi runtime
Prinsip dasar
- terima input sebagai
unknown, bukan langsung tipe domain; - validasi struktur dan nilai yang penting;
- setelah lolos, baru sempitkan tipe;
- jangan menaruh asumsi business logic sebelum validasi selesai.
Contoh sederhana
function parseUser(raw: unknown): User | null {
if (!isUser(raw)) {
return null;
}
return raw;
}Kalau memakai schema library
Pola yang umum adalah memakai schema validator seperti Zod, Valibot, Yup, atau library sejenis untuk:
- validasi input runtime,
- infer tipe TypeScript dari schema,
- menjaga satu sumber kebenaran antara runtime dan compile-time.
Contoh bentuk pola, tanpa bergantung ke library tertentu:
// schema -> validate -> inferred type
const result = validateUserPayload(payload);
if (!result.ok) {
throw new Error(result.error);
}
const user = result.data; // sudah tervalidasiJebakan umum saat runtime tidak sesuai compile-time
1. Type assertion dipakai sebagai pintasan
const user = payload as User;Ini hanya memberi tahu compiler untuk percaya, bukan memvalidasi data. Kalau payload salah, bug-nya tetap muncul di runtime.
2. Data JSON dianggap pasti benar
JSON.parse() mengembalikan any secara historis atau unknown tergantung wrapper yang dipakai, tapi intinya hasil parse tetap harus divalidasi sebelum dipakai.
3. as dipakai untuk menutup error alih-alih memperbaiki model
Kalau kamu sering perlu as SomeType, biasanya ada masalah di boundary data, bukan di TypeScript-nya.
4. Optional property disalahartikan sebagai pasti ada
type Result = {
data?: string;
};data belum tentu ada. Kalau dipakai tanpa guard, branch runtime bisa gagal walau compiler tidak langsung protes karena ada jalur yang tampak aman.
5. Narrowing yang bergantung pada asumsi yang rapuh
Misalnya cek satu field saja padahal field lain juga wajib. TypeScript akan percaya kalau custom guard kamu bilang lolos, jadi isi guard harus benar-benar ketat.
6. Union terlalu besar tanpa discriminator
Kalau shape banyak tapi tidak ada pembeda yang jelas, narrowing jadi berantakan dan kode biasanya berakhir penuh as.
Pola yang aman untuk kerja harian
Untuk input dari luar
- Terima data sebagai
unknown. - Validasi runtime.
- Jika valid, ubah menjadi tipe domain.
- Jalankan business logic.
Untuk state internal aplikasi
- Modelkan state sebagai discriminated union.
- Gunakan
switchatauifberbasis discriminator. - Tambahkan exhaustive check.
- Hindari boolean flag yang saling bertabrakan kalau bisa.
Untuk helper reusable
- pakai custom type guard untuk narrowing yang sering diulang,
- pakai assertion function untuk jalur yang harus gagal cepat,
- pakai schema validator untuk boundary data yang tidak tepercaya.
Ringkasan praktik baik
- Gunakan
unknownuntuk data yang belum dipercaya. - Jangan lompat ke
assebelum validasi. - Pakai discriminated union untuk state atau variant yang jelas.
- Pakai
switch+neveruntuk exhaustive check. - Jadikan runtime validation sebagai gerbang sebelum business logic.
- Anggap compile-time type sebagai kontrak, bukan jaminan runtime.
Referensi terkait
Types, Interfaces, Generics, Narrowing, dan Utility Types di TypeScriptAdvanced Types TypeScript: Unions, Intersections, Mapped Types, Conditional Types,infer, dan Type GuardsPHP Validation, Form Requests, Redirects, dan Error Bagsuntuk pola validasi di layer serverFetch API, Async Data Flow, JSON Parsing, dan Error Handling Dasaruntuk boundary data di frontend