Error Handling with Types
TypeScript Programming
Error Handling with Types
Error handling in TypeScript can go far beyond try-catch blocks. By leveraging the type system, you can make errors explicit in function signatures, force callers to handle failure cases, and eliminate entire classes of unhandled exceptions. Patterns like the Result type (inspired by Rust and functional programming) encode success and failure as data rather than exceptions, making error flows visible and type-checked. This approach is particularly valuable in APIs and business logic where understanding and handling every failure mode is critical for reliability.
The Problem with Exceptions
JavaScript exceptions are untyped and invisible in function signatures. A function that might throw gives no compile-time indication of this fact, leaving callers to guess or read documentation to know what might fail.
// This function throws, but the signature does not reveal it
function parseJSON(text: string): unknown {
return JSON.parse(text); // Throws SyntaxError on invalid JSON
}
// Callers might forget to wrap in try-catch
const data = parseJSON("invalid"); // Runtime crash!
Custom Error Classes
Creating a hierarchy of typed error classes makes it possible to use instanceof guards to handle specific error types differently.
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500
) {
super(message);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string | number) {
super(`${resource} with id "${id}" not found`, "NOT_FOUND", 404);
this.name = "NotFoundError";
}
}
class ValidationError extends AppError {
constructor(
public readonly field: string,
message: string
) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
}
}
function handleError(error: unknown): { status: number; body: object } {
if (error instanceof NotFoundError) {
return { status: 404, body: { error: error.message } };
}
if (error instanceof ValidationError) {
return { status: 400, body: { error: error.message, field: error.field } };
}
if (error instanceof AppError) {
return { status: error.statusCode, body: { error: error.message } };
}
return { status: 500, body: { error: "Internal server error" } };
}
The Result Type Pattern
The Result type makes success and failure explicit in the return type. Callers must check which variant they received before accessing the data, ensuring errors cannot be accidentally ignored.
// Define a Result type as a discriminated union
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Helper functions to create Result values
function Ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function Err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Use Result instead of throwing
function safeDivide(a: number, b: number): Result<number, string> {
if (b === 0) {
return Err("Division by zero");
}
return Ok(a / b);
}
const result = safeDivide(10, 0);
if (result.ok) {
console.log(`Result: ${result.value}`); // value is number
} else {
console.log(`Error: ${result.error}`); // error is string
}
// Parsing with Result
function safeParseJSON<T>(text: string): Result<T, SyntaxError> {
try {
return Ok(JSON.parse(text) as T);
} catch (e) {
return Err(e as SyntaxError);
}
}
interface Config { port: number; host: string; }
const parsed = safeParseJSON<Config>('{"port": 3000, "host": "localhost"}');
if (parsed.ok) {
console.log(`Server at ${parsed.value.host}:${parsed.value.port}`);
}
Chaining Results
You can add utility methods to work with Result values, chaining operations that might fail in a clean, readable pipeline.
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
if (result.ok) {
return Ok(fn(result.value));
}
return result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) {
return fn(result.value);
}
return result;
}
// Chain multiple operations
const finalResult = flatMap(
safeParseJSON<{ value: number }>('{"value": 100}'),
(config) => safeDivide(config.value, 3)
);
Tip: The Result pattern shines in business logic and data processing pipelines where you want to handle errors without exceptions. For I/O operations and framework code, traditional try-catch is often more practical. Use both approaches where they fit best.
Key Takeaways
- JavaScript exceptions are untyped and invisible in function signatures, making error handling error-prone.
- Custom error class hierarchies enable
instanceof-based error handling with typed properties. - The Result type pattern makes success and failure explicit, forcing callers to handle both cases.
- Helper functions
Ok()andErr()create Result values cleanly. - Chain Result operations with
mapandflatMapfor clean error propagation.