Javascript Error Handling Try Catch Throw Custom Error Debugging
Definisi
Error handling adalah cara kita membuat program tetap bisa bertahan saat sesuatu gagal. Di JavaScript, kegagalan bisa datang dari banyak arah: parsing data, akses properti yang tidak ada, validasi input, request network, operasi async, sampai bug logika yang baru kelihatan di runtime.
Tujuan error handling bukan untuk menyembunyikan masalah. Tujuannya justru untuk:
- menghentikan alur yang tidak aman pada titik yang tepat
- memberi pesan error yang cukup jelas untuk developer atau user
- menjaga aplikasi tetap punya boundary yang rapi antara kegagalan teknis dan tampilan UI
- memudahkan debugging saat bug muncul di produksi atau saat testing
Kalau ditulis dengan baik, error handling membuat flow aplikasi lebih mudah dibaca. Kalau ditulis asal-asalan, error justru hilang, tertelan, atau muncul jauh dari sumber masalah.
Kenapa penting
Banyak bug JavaScript bukan bug yang langsung membuat aplikasi crash. Yang lebih sering terjadi adalah:
- data salah bentuk lalu dipakai terus
- request gagal tapi UI tetap mengira data sukses
- Promise rejection tidak tertangani
- error dilempar ulang tanpa konteks
catchdipakai terlalu luas sehingga sumber error jadi kabur
Di titik ini, error handling yang rapi membantu dua hal sekaligus:
- menjaga alur runtime supaya kegagalan tidak menyebar ke bagian lain
- menjaga observability supaya kita tahu error itu datang dari mana dan kenapa
Cara kerja dasar
JavaScript punya dua bentuk kegagalan yang umum:
1) Synchronous throw
Kode synchronous bisa melempar error langsung dengan throw.
function parseAge(value) {
const age = Number(value);
if (Number.isNaN(age)) {
throw new Error('Age harus berupa angka');
}
return age;
}Kalau throw terjadi, eksekusi fungsi berhenti saat itu juga dan kontrol pindah ke catch terdekat yang bisa menangkapnya.
2) Async rejection
Operasi async biasanya gagal lewat Promise rejection, bukan throw biasa.
async function loadUser() {
const response = await fetch('/api/user');
if (!response.ok) {
throw new Error('Gagal memuat user');
}
return response.json();
}Di dalam async function, throw akan berubah menjadi Promise rejection. Ini penting karena error async hanya tertangkap kalau kita memang menangkap Promise-nya dengan benar.
Kapan error ditangani di level function
Tangani error di level function kalau fungsi itu memang punya tanggung jawab untuk:
- memvalidasi input
- memastikan invariants internal tetap benar
- mengubah error mentah menjadi error yang lebih bermakna
- menghentikan operasi lebih awal sebelum data yang salah menyebar
Contoh: validasi input lokal
function getDiscountPrice(price, discount) {
if (typeof price !== 'number') {
throw new TypeError('price harus number');
}
if (typeof discount !== 'number') {
throw new TypeError('discount harus number');
}
return price - price * discount;
}Di sini, fungsi berhak menolak input yang salah. Itu lebih baik daripada membiarkan hasil salah muncul diam-diam.
Contoh: ubah error teknis menjadi error domain
function normalizeUser(user) {
if (!user || typeof user !== 'object') {
throw new TypeError('user wajib object');
}
if (!user.id) {
throw new Error('User tidak punya id');
}
return {
id: String(user.id),
name: user.name ?? 'Unknown',
};
}Fungsi ini tidak perlu tahu UI atau API. Ia hanya menjaga bentuk data yang benar di level domain.
Prinsip praktis
Di level function, kita biasanya ingin:
- menolak input yang tidak valid secepat mungkin
- tidak membiarkan fungsi lanjut dengan state yang rusak
- melempar error yang cukup spesifik untuk caller
Kalau fungsi kecil tahu apa yang salah, caller di atasnya jadi lebih mudah menentukan tindak lanjut.
Kapan error ditangani di boundary UI/API
Boundary adalah titik keluar-masuk sistem: handler UI, controller API, route handler, action, atau layer integrasi ke layanan luar.
Tangani error di boundary kalau tujuannya adalah:
- mengubah error internal menjadi response yang cocok untuk user atau client
- logging dan observability
- fallback UI
- memetakan error teknis ke status code / pesan yang aman
- mencegah crash di level aplikasi
Boundary UI
Di UI, error biasanya ditangani agar user mendapat umpan balik yang jelas.
async function handleSubmit() {
try {
await saveProfile(formData);
showToast('Profil tersimpan');
} catch (error) {
showToast('Gagal menyimpan profil');
console.error(error);
}
}Boundary UI tidak selalu perlu tahu detail teknisnya. Yang penting:
- user tahu aksi gagal
- aplikasi tidak diam saja
- developer tetap punya log atau telemetry
Boundary API
Di API, error biasanya diterjemahkan menjadi response yang konsisten.
app.post('/users', async (req, res) => {
try {
const user = await createUser(req.body);
res.status(201).json(user);
} catch (error) {
console.error(error);
res.status(400).json({
message: 'Gagal membuat user',
});
}
});Di boundary API, kita sering memilih untuk:
- menangkap error yang datang dari layer bawah
- memetakan error ke status code yang sesuai
- tidak membocorkan stack trace atau detail sensitif ke client
Kenapa tidak semua error ditangani di boundary saja
Kalau semua error dibiarkan naik ke boundary, biasanya akibatnya:
- source error terlalu jauh dari titik masalah
- fungsi-fungsi bawah jadi terlalu permisif
- validasi terlambat
- error message jadi generik semua
Jadi polanya bukan “tangani semuanya di boundary”, melainkan:
- fungsi menjaga kebenaran lokal
- boundary mengubah error menjadi respons yang layak keluar sistem
try/catch dipakai untuk apa
try/catch dipakai untuk mengeksekusi kode yang mungkin gagal dan menangkap error yang dilempar di blok itu.
try {
const data = JSON.parse(input);
console.log(data);
} catch (error) {
console.error('JSON tidak valid');
}Yang perlu diingat
trybukan alat untuk menutup semua bugcatchbukan tempat untuk menelan error tanpa jejaktry/catchpaling berguna kalau kita ingin memutus alur dengan cara yang terkontrol
Pola yang baik
try {
const parsed = JSON.parse(input);
return parsed;
} catch (error) {
throw new Error(`Input JSON tidak valid: ${error.message}`);
}Di sini, error asli diperkaya konteksnya. Ini membantu debugging tanpa kehilangan alasan kegagalan.
throw dan kapan dipakai
throw dipakai saat program harus berhenti karena kondisi yang tidak bisa dilanjutkan.
Pakai throw ketika:
- input melanggar kontrak fungsi
- state internal tidak konsisten
- operasi penting gagal dan tidak ada fallback aman
- kita ingin memaksa caller menanganinya
Jangan pakai throw untuk hal yang sebenarnya hanya status biasa. Misalnya, kondisi “data belum ada” kadang lebih cocok sebagai null, undefined, atau result object, bukan exception.
Contoh yang tepat
function requireAuth(user) {
if (!user) {
throw new Error('User belum login');
}
return user;
}Contoh yang terlalu agresif
function findUser(users, id) {
const user = users.find(item => item.id === id);
if (!user) {
throw new Error('User tidak ditemukan');
}
return user;
}Ini bisa tepat di beberapa domain, tapi kadang lebih enak mengembalikan null jika “tidak ditemukan” adalah hasil normal yang memang diharapkan. Pilih sesuai kontrak fungsi.
Custom Error
Custom error berguna kalau error biasa belum cukup jelas untuk membedakan jenis kegagalan.
Contoh custom error
class ValidationError extends Error {
constructor(message, details = {}) {
super(message);
this.name = 'ValidationError';
this.details = details;
}
}Dipakai untuk apa
Custom error membantu kita:
- membedakan error validasi dari error network
- memetakan status code dengan lebih rapi
- memberi metadata tambahan untuk debugging
- membuat
catchlebih spesifik
Contoh penggunaan
function validateEmail(email) {
if (typeof email !== 'string' || !email.includes('@')) {
throw new ValidationError('Email tidak valid', {
field: 'email',
value: email,
});
}
}Di boundary, kita bisa mengenali jenis error ini:
try {
validateEmail(input.email);
} catch (error) {
if (error instanceof ValidationError) {
return res.status(422).json({ message: error.message, details: error.details });
}
return res.status(500).json({ message: 'Internal server error' });
}Kapan custom error relevan
Custom error layak dipakai saat:
- aplikasi punya beberapa jenis error yang perlu dibedakan
- error perlu metadata tambahan
- error akan dipetakan ke response atau log yang berbeda
- domain logic sudah cukup besar untuk butuh klasifikasi error
Kalau proyek masih kecil, custom error tidak wajib. Tapi begitu error handling mulai bercabang, custom error biasanya sangat membantu.
Alur error propagation
Error yang dilempar di level bawah biasanya naik ke caller terdekat. Kalau tidak ditangkap, error terus naik sampai boundary terluar.
Cara membaca diagram ini
- fungsi kecil menjaga kontraknya sendiri
- caller di tengah bisa memilih menambah konteks atau mengubah alur
- boundary adalah tempat terakhir untuk menerjemahkan error menjadi perilaku aplikasi
Debugging mindset
Debugging error handling bukan cuma soal “menangkap error”. Yang lebih penting adalah cara berpikir saat error muncul.
1) Cari sumber, bukan cuma gejala
Kalau error muncul di UI, belum tentu sumbernya di UI. Bisa jadi sumbernya:
- data API salah bentuk
- parser gagal
- validasi dilewati
- asumsi state keliru
Pertanyaan pertama yang bagus biasanya:
- error ini dilempar dari mana?
- input apa yang membuatnya gagal?
- apa kontrak fungsi yang dilanggar?
2) Jangan hilangkan konteks
Error yang terlalu umum seperti Something went wrong memang aman untuk user, tapi tidak cukup untuk debugging.
Gunakan dua lapis pesan:
- pesan aman untuk user
- detail teknis untuk log/developer
3) Periksa boundary terakhir
Kalau error tampak “menghilang”, cek apakah:
catchkosong- Promise rejection tidak di-return
- error di-swallow oleh handler global
- logging hanya ada di cabang yang jarang dipakai
4) Bedakan recoverable vs non-recoverable
Tidak semua error harus dipulihkan.
- Recoverable: retry request, tampilkan fallback, minta input ulang
- Non-recoverable: schema rusak, invariant dilanggar, state internal korup
Kalau semua error dipaksa “ditangani”, kode sering jadi lebih rapuh.
Jebakan umum saat menangani async error
Bagian ini paling sering memunculkan bug yang susah dilacak.
1) try/catch di luar await tidak menangkap rejection
try {
fetch('/api/data').then(res => res.json());
} catch (error) {
console.error('Tidak akan masuk sini untuk rejection async');
}Kenapa? Karena error async terjadi setelah blok try selesai. Kalau pakai Promise, error harus ditangani lewat .catch() atau await di dalam try/catch.
try {
const res = await fetch('/api/data');
const data = await res.json();
} catch (error) {
console.error('Ini menangkap error async');
}2) Lupa return atau await di chain Promise
function save() {
return apiCall().then(() => {
doNextStep();
});
}Kalau ada Promise di dalam callback yang tidak di-return, error bisa lepas dari rantai yang sedang ditangani.
3) catch kosong
try {
await riskyOperation();
} catch (error) {
// kosong
}Ini salah satu jebakan paling mahal. Error memang tidak terlihat, tapi bug tetap ada. Minimal log, rethrow, atau ubah menjadi fallback yang jelas.
4) Menangkap terlalu luas lalu menutupi bug lain
try {
await loadData();
renderUI();
} catch (error) {
showFallback();
}Kalau renderUI() bug, error itu ikut tertutup dan kelihatan seperti gagal load data. Pisahkan area yang memang rawan gagal.
5) Tidak membedakan error network, parsing, dan domain
Semua error async sering diperlakukan sama. Padahal:
fetchgagal jaringanresponse.ok === falseberarti server menolakJSON.parsegagal format- validasi domain gagal karena data tidak sesuai aturan bisnis
Kalau semua disamaratakan, debugging jadi lebih lambat.
6) Mencampur callback style dan Promise tanpa strategi
Kalau callback lama, Promise, dan async/await dipakai bersamaan tanpa pola yang jelas, error path jadi mudah terlewat.
Pola praktis yang enak dipakai
Pola 1: validasi di fungsi, mapping di boundary
function createPayload(input) {
if (!input.title) {
throw new ValidationError('Title wajib diisi', { field: 'title' });
}
return {
title: input.title.trim(),
};
}
app.post('/posts', async (req, res) => {
try {
const payload = createPayload(req.body);
const result = await savePost(payload);
res.status(201).json(result);
} catch (error) {
if (error instanceof ValidationError) {
return res.status(422).json({ message: error.message, details: error.details });
}
console.error(error);
return res.status(500).json({ message: 'Gagal memproses request' });
}
});Pola 2: tambah konteks lalu rethrow
async function loadDashboard() {
try {
return await fetchDashboardData();
} catch (error) {
throw new Error(`loadDashboard gagal: ${error.message}`);
}
}Pola 3: boundary UI memberi fallback yang jelas
async function onSaveClick() {
try {
setSaving(true);
await saveSettings();
notify('Settings tersimpan');
} catch (error) {
notify('Gagal menyimpan settings');
console.error(error);
} finally {
setSaving(false);
}
}Catatan implementasi
- Jangan gunakan
try/catchuntuk semua baris dalam satu file hanya karena merasa aman. - Kalau sebuah fungsi bisa gagal secara wajar, dokumentasikan kontraknya: return normal atau throw.
- Saat melempar ulang error, pertimbangkan untuk mempertahankan
causebila environment mendukung.
throw new Error('Gagal memuat profil', { cause: error });- Di layer API, jangan kirim stack trace mentah ke client kecuali memang lingkungan debug.
- Di UI, pesan untuk user boleh singkat, tapi log internal harus cukup kaya konteks.
Referensi cepat
throw: menghentikan eksekusi normal dan melempar errortry/catch: menangkap error synchronous, dan async jika error berasal dariawait- custom error: class error sendiri untuk klasifikasi dan metadata
- boundary: lapisan UI/API yang menerjemahkan error ke output yang aman
- debugging mindset: cari sumber, pertahankan konteks, bedakan recoverable vs non-recoverable
Kesimpulan
Pola yang paling aman biasanya sederhana:
- validasi dan invariants dijaga di level function
- error diterjemahkan di boundary UI/API
- async error ditangani dengan
await+try/catchatau.catch()yang benar - custom error dipakai kalau klasifikasi error mulai penting
- debugging fokus ke sumber masalah, bukan hanya gejala yang terlihat di layar
Kalau pola ini konsisten, error handling jadi alat kontrol alur, bukan sekadar formalitas.