Exceptions #
Error handling is one of the most poorly written aspects of TypeScript code — not because the concept is hard, but because many habits from JavaScript aren’t fully safe in TypeScript. The most important change in TypeScript 4.0 is that the error variable in a catch block is now typed unknown, not any — this forces you to do type narrowing before accessing error properties, which is a far safer practice. Beyond the try-catch mechanism, TypeScript supports modern approaches like the Result pattern that turn errors into ordinary values that can be returned instead of thrown, making error handling more explicit and easier to test.
try-catch-finally — The Basics of Error Handling
#
try-catch-finally is the main error handling mechanism inherited from JavaScript. In TypeScript with strict: true, the error variable in a catch block is typed unknown — this is an important change compared to plain JavaScript:
function bagi(a: number, b: number): number {
if (b === 0) {
throw new Error("Pembagian dengan nol tidak diizinkan");
}
return a / b;
}
try {
const hasil = bagi(10, 0);
console.log(`Hasil: ${hasil}`);
} catch (error) {
// In strict TypeScript: error is typed 'unknown', not 'any'
// ANTI-PATTERN: Accessing properties directly without narrowing
// console.error(error.message); // ✗ Error: 'error' is of type 'unknown'
// CORRECT: Narrow first
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
console.error(`Stack: ${error.stack}`);
} else {
// Someone could throw a non-Error value, e.g: throw "string error"
console.error(`Error tidak dikenal: ${String(error)}`);
}
} finally {
// Always executed, whether there's an error or not
console.log("Operasi selesai — membersihkan resource");
}
Why unknown Is Better Than any
#
// With 'any' (plain JavaScript) — unsafe
try {
riskyOperation();
} catch (error: any) {
error.message; // ✓ No compilation error
error.metodeTidakAda(); // ✓ No compilation error — but crashes at runtime!
}
// With 'unknown' (strict TypeScript) — forces validation
try {
riskyOperation();
} catch (error) {
// error.message; // ✗ Compilation error: Object is of type 'unknown'
if (error instanceof Error) {
error.message; // ✓ Safe — TypeScript knows this is an Error
}
}
finally — Guaranteed Cleanup
#
The finally block always runs — even if the try block has a return or throw:
async function ambilDataDenganKoneksi(id: string): Promise<string> {
const koneksi = await bukaKoneksi();
try {
const data = await koneksi.query(`SELECT * FROM data WHERE id = '${id}'`);
return data;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Gagal mengambil data: ${error.message}`);
}
throw error;
} finally {
// The connection is always closed — on success or failure
await koneksi.tutup();
console.log("Koneksi database ditutup");
}
}
// Mock function for the example
async function bukaKoneksi() {
return {
query: async (sql: string) => `Data untuk query: ${sql}`,
tutup: async () => {},
};
}
Custom Error Classes — A Structured Error Hierarchy #
Throwing a generic new Error("message") makes error handling at the top level difficult — you can’t distinguish error types without inspecting the message (which is prone to typos). The solution is building a custom error class hierarchy:
// The base application error class — add useful metadata
class AppError extends Error {
readonly kodeError: string;
readonly statusHttp: number;
readonly timestamp: Date;
constructor(pesan: string, kodeError: string, statusHttp: number = 500) {
super(pesan);
this.name = this.constructor.name; // Automatically fills in the class name
this.kodeError = kodeError;
this.statusHttp = statusHttp;
this.timestamp = new Date();
// Fix for the prototype chain in TypeScript compiled to ES5
Object.setPrototypeOf(this, new.target.prototype);
}
}
// Specific errors — each carries its own context
class ErrorValidasi extends AppError {
readonly field: string;
constructor(field: string, pesan: string) {
super(pesan, "VALIDATION_ERROR", 400);
this.field = field;
}
}
class ErrorTidakDitemukan extends AppError {
readonly resource: string;
readonly idDicari: string;
constructor(resource: string, id: string) {
super(`${resource} dengan ID '${id}' tidak ditemukan`, "NOT_FOUND", 404);
this.resource = resource;
this.idDicari = id;
}
}
class ErrorTidakDiotorisasi extends AppError {
constructor(aksi: string) {
super(`Tidak memiliki izin untuk: ${aksi}`, "UNAUTHORIZED", 403);
}
}
class ErrorKoneksiDatabase extends AppError {
readonly queryGagal?: string;
constructor(pesan: string, query?: string) {
super(pesan, "DATABASE_ERROR", 503);
this.queryGagal = query;
}
}
Catching Errors by Hierarchy #
async function ambilProduk(id: string, penggunaId: string): Promise<Produk> {
// Input validation
if (!id.startsWith("PRD-")) {
throw new ErrorValidasi("id", `Format ID produk tidak valid: ${id}`);
}
// Authorization check
const bolehLihat = await cekIzin(penggunaId, "produk:baca");
if (!bolehLihat) {
throw new ErrorTidakDiotorisasi("membaca produk");
}
// Fetch from the database
const produk = await db.cariById(id);
if (!produk) {
throw new ErrorTidakDitemukan("Produk", id);
}
return produk;
}
// Handler at the controller/route level
async function handlerAmbilProduk(id: string, penggunaId: string): Promise<void> {
try {
const produk = await ambilProduk(id, penggunaId);
console.log("Produk ditemukan:", produk);
} catch (error) {
// Catch by type — from the most specific to the most general
if (error instanceof ErrorValidasi) {
console.error(`[400] Validasi gagal pada field '${error.field}': ${error.message}`);
} else if (error instanceof ErrorTidakDiotorisasi) {
console.error(`[403] Tidak diotorisasi: ${error.message}`);
} else if (error instanceof ErrorTidakDitemukan) {
console.error(`[404] ${error.resource} tidak ditemukan (ID: ${error.idDicari})`);
} else if (error instanceof AppError) {
console.error(`[${error.statusHttp}] ${error.kodeError}: ${error.message}`);
} else if (error instanceof Error) {
console.error(`[500] Error tidak terduga: ${error.message}`);
} else {
console.error(`[500] Error tidak diketahui:`, error);
}
}
}
// Mock functions
async function cekIzin(userId: string, izin: string): Promise<boolean> { return true; }
const db = { cariById: async (id: string): Promise<Produk | null> => null };
interface Produk { id: string; nama: string; harga: number; }
Re-throwing — Throwing Again Correctly #
Re-throwing is the technique of catching an error, doing something (logging, enrichment), then throwing it again so it gets handled at a higher level:
class LayananPembayaran {
async prosesPembayaran(orderId: string, jumlah: number): Promise<string> {
try {
const respon = await this.panggilAPIGateway(orderId, jumlah);
return respon.transactionId;
} catch (error) {
// Log the error with rich context
console.error("[LayananPembayaran] Pembayaran gagal:", {
orderId,
jumlah,
waktu: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
});
// ANTI-PATTERN: A re-throw that loses the original context
// throw new Error("Pembayaran gagal"); // The original stack trace is lost!
// CORRECT: Wrap the error with extra context but preserve the cause
if (error instanceof Error) {
throw new ErrorGatewayPembayaran(
`Pembayaran untuk order ${orderId} gagal: ${error.message}`,
{ cause: error } // ES2022 — stores the original error as the cause
);
}
throw error; // Re-throw if it isn't an Error instance
}
}
private async panggilAPIGateway(orderId: string, jumlah: number): Promise<{ transactionId: string }> {
throw new Error("Timeout koneksi ke payment gateway");
}
}
class ErrorGatewayPembayaran extends AppError {
constructor(pesan: string, options?: ErrorOptions) {
super(pesan, "PAYMENT_GATEWAY_ERROR", 502);
if (options?.cause instanceof Error) {
// Access the cause error via .cause
console.log("Penyebab:", options.cause.message);
}
}
}
The Result Pattern — Errors as Values #
The Result pattern is an alternative to throw-catch that makes errors an explicit part of a function’s return type. It borrows concepts from languages like Rust and Go:
// An expressive Result type
type Result<T, E extends Error = Error> =
| { berhasil: true; data: T; error?: never }
| { berhasil: false; data?: never; error: E };
// Helper functions for creating Results
const ok = <T>(data: T): Result<T, never> => ({ berhasil: true, data });
const gagal = <E extends Error>(error: E): Result<never, E> => ({ berhasil: false, error });
// A function returning a Result instead of throwing
async function parseJSON<T>(jsonString: string): Promise<Result<T, SyntaxError>> {
try {
const data = JSON.parse(jsonString) as T;
return ok(data);
} catch (error) {
return gagal(error as SyntaxError);
}
}
async function ambilKonfigurasi(path: string): Promise<Result<KonfigurasiApp, AppError>> {
try {
// Simulate reading a file
const isi = await bacaFile(path);
const hasil = await parseJSON<KonfigurasiApp>(isi);
if (!hasil.berhasil) {
return gagal(new AppError(
`File konfigurasi tidak valid: ${hasil.error.message}`,
"CONFIG_PARSE_ERROR"
));
}
return ok(hasil.data);
} catch (error) {
return gagal(new AppError(
`Gagal membaca konfigurasi dari ${path}`,
"CONFIG_READ_ERROR"
));
}
}
// Usage — no try-catch at the caller level
async function inisialisasiApp(): Promise<void> {
const hasil = await ambilKonfigurasi("/etc/app/config.json");
if (!hasil.berhasil) {
console.error(`Inisialisasi gagal [${hasil.error.kodeError}]: ${hasil.error.message}`);
process.exit(1);
}
// TypeScript knows hasil.data is of type KonfigurasiApp here
console.log(`App dimulai dengan environment: ${hasil.data.environment}`);
}
// Mock types and functions
interface KonfigurasiApp { environment: string; }
async function bacaFile(path: string): Promise<string> { return "{}"; }
When to Throw vs Return Result #
Use throw if:
✓ The error is truly unexpected (bugs, infrastructure down)
✓ You're in a lower layer (utilities, libraries) that doesn't know how to recover
✓ The condition "should never happen" (programmer errors)
✓ The error must propagate far up without being handled mid-way
Use Result if:
✓ The error is part of the normal business flow (validation, not found)
✓ Callers are expected to handle all possible errors
✓ You want error handling visible in the function signature
✓ You're writing easily testable code (no need to mock throws)
Assertion Functions — Validating Prerequisites #
Assertion functions throw an error if a condition isn’t met, and tell TypeScript that the value has a certain type after the assertion succeeds:
// A basic assertion function
function pastikan(kondisi: boolean, pesan: string): asserts kondisi {
if (!kondisi) {
throw new Error(`Assertion gagal: ${pesan}`);
}
}
// An assertion function with type narrowing
function pastikanBukanNull<T>(
nilai: T | null | undefined,
namaVariabel: string
): asserts nilai is T {
if (nilai === null || nilai === undefined) {
throw new ErrorValidasi(namaVariabel, `${namaVariabel} tidak boleh kosong`);
}
}
// An assertion function for a specific type
function pastikanString(nilai: unknown, namaField: string): asserts nilai is string {
if (typeof nilai !== "string") {
throw new ErrorValidasi(namaField, `${namaField} harus bertipe string, bukan ${typeof nilai}`);
}
}
// Usage
function prosesOrder(
orderId: unknown,
penggunaId: string | null,
items: unknown[]
): void {
// After the assertion, TypeScript narrows the type automatically
pastikanString(orderId, "orderId");
// orderId: string here
pastikanBukanNull(penggunaId, "penggunaId");
// penggunaId: string (not string | null) here
pastikan(items.length > 0, "Order harus memiliki minimal satu item");
console.log(`Memproses order ${orderId} untuk pengguna ${penggunaId}`);
console.log(`Jumlah item: ${items.length}`);
}
Correct Async Error Handling #
Errors in async code often slip through if not handled properly. There are several traps to watch out for:
// ANTI-PATTERN: A Promise that isn't awaited — errors silently ignored
function mulaiProses(): void {
ambilData(); // ✗ The Promise isn't awaited, errors can vanish without a trace
}
// ANTI-PATTERN: A try-catch that doesn't catch async errors
async function contohSalah(): Promise<void> {
try {
setTimeout(async () => {
await operasiGagal(); // ✗ An error inside setTimeout isn't caught by the outer try-catch
}, 1000);
} catch (error) {
console.error("Error ini tidak akan tertangkap!"); // Will never be called
}
}
// CORRECT: Always await Promises inside try-catch
async function contohBenar(): Promise<void> {
try {
await operasiGagal(); // ✓ The error is caught because it's awaited
} catch (error) {
if (error instanceof Error) {
console.error(`Error: ${error.message}`);
}
}
}
// CORRECT: Catch unhandled promise rejections globally (Node.js)
process.on("unhandledRejection", (reason, promise) => {
console.error("Promise rejection yang tidak ditangani:", reason);
// Log to a monitoring service (Sentry, Datadog, etc.)
process.exit(1); // Exit with an error code
});
async function operasiGagal(): Promise<void> {
throw new Error("Operasi gagal");
}
Error Handling Flow in an Application #
flowchart TD
A[Error Occurs] --> B{Error Type?}
B -- Business error\\nvalidation, not found --> C[Return as Result\\nor throw custom AppError]
B -- Infrastructure error\\nDB, network, timeout --> D[Log + throw AppError\\nwith context]
B -- Programmer bug\\nassertion failed --> E[Throw a regular Error\\ndon't catch]
B -- External error\\nAPI, parsing --> F[Wrap in a custom error\\npreserve the cause]
C --> G{At which level?}
D --> G
F --> G
G -- Lower layer\\nservice, repository --> H[Re-throw with enrichment]
G -- Controller layer\\nAPI handler --> I[Capture and format\\ninto an HTTP response]
G -- Top layer\\nmain, entrypoint --> J[Log + exit / retry]
H --> G
I --> K[Send response\\n4xx or 5xx]
J --> L[Monitoring + Alert]
style C fill:#51cf66,color:#fff
style D fill:#fcc419,color:#000
style E fill:#ff6b6b,color:#fff
style F fill:#339af0,color:#fff
style K fill:#51cf66,color:#fff
style L fill:#cc5de8,color:#fffSummary #
errorincatchis typedunknown— always doinstanceof Errorbefore accessing.messageor.stack; never cast toanyto avoid narrowing because that returns you to unsafe JavaScript behavior.Object.setPrototypeOf(this, new.target.prototype)— add this line in custom error class constructors to fix the prototype chain when compiled to ES5; without it,instanceofwon’t work correctly.- A custom error hierarchy from a base
AppErrorenables per-category error handling withinstanceof; each specific error can carry additional metadata (field, resource ID, HTTP status).- Re-throw with
cause(ES2022) preserves the original stack trace when wrapping an error with extra context —throw new AppError("pesan", { cause: error })is far better than throwing a new error that loses the trail.- The Result pattern
{ berhasil: true; data: T } | { berhasil: false; error: E }makes errors an explicit part of the function contract — callers can’t forget to handle errors because TypeScript forces the check.- Assertion functions (
asserts kondisiorasserts nilai is T) validate prerequisites and narrow types automatically after a successful assertion — ideal for parameter validation at the start of functions.- Don’t mix try-catch with callbacks or setTimeout — errors inside async callbacks aren’t caught by an outer try-catch; always
awaitPromises inside try-catch.- Register a global error handler with
process.on("unhandledRejection", ...)in Node.js to catch unhandled Promise rejections before they cause unexpected crashes.- Throw for bugs and impossible conditions, Result for business errors — failed input validation is a normal part of the business flow and is better represented as a Result; a database connection crash is an unexpected condition worth throwing.