Error Handling in TypeScript: Patterns That Scale
Move beyond try-catch soup: discriminated unions, Result types, and error boundaries that make TypeScript codebases more reliable and maintainable.

Most TypeScript codebases handle errors the same way JavaScript always has — try/catch blocks everywhere, unknown caught errors cast to any, and hope that nothing important slips through. The type system that was supposed to protect you is completely blind to failure paths. There's a better way, and it doesn't require a library.
Why Try-Catch Doesn't Scale
The fundamental problem with try/catch as your primary error handling strategy is that errors become invisible at the type level. A function's signature promises it returns User, but it might throw five different exceptions. The caller has no way to know without reading the implementation.
// ❌ The signature lies — this can fail in at least three ways
async function getUser(id: string): Promise<User> {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) throw new Error("User not found");
return parseUser(row); // can also throw if schema changed
}
// ✅ The type signature tells the whole story
async function getUser(
id: string,
): Promise<Result<User, "NOT_FOUND" | "PARSE_ERROR" | "DB_ERROR">> {
try {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) return err("NOT_FOUND");
const parsed = parseUser(row);
if (!parsed.ok) return err("PARSE_ERROR");
return ok(parsed.value);
} catch {
return err("DB_ERROR");
}
}The second version makes a contract: callers must handle failure. The compiler enforces it.
Building a Lightweight Result Type
You don't need fp-ts or neverthrow for most applications. A minimal implementation covers 90% of real use cases.
type Result<T, E = string> = { ok: true; value: T } | { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Type guard for direct discrimination
function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
return result.ok;
}The discriminated union means TypeScript narrows automatically when you check result.ok. No casting, no as assertions — the compiler tracks which branch you're in.
When to Throw vs. When to Return
Not every failure should be a Result. Mixing the two incorrectly creates noise. Use this heuristic:
| Scenario | Approach | Reasoning |
|---|---|---|
| Expected failure (not found, validation error) | Result<T, E> | Caller is expected to handle it |
| Programmer bug (null dereference, wrong assumption) | throw | Should crash loudly — it's a bug |
| Infrastructure failure (network timeout, disk full) | throw at boundary | Retry logic belongs at the edge |
| User-facing form validation | Result<T, ValidationError[]> | Multiple errors, structured feedback |
The goal is that throw means "this should never happen in correct code." When it does happen, you want it loud and traceable.
Composing Results
The real power comes when you chain operations that can each fail. Without a helper, this turns into nested pattern matching.
// Utility for chaining fallible operations
function andThen<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>,
): Result<U, E> {
if (!result.ok) return result;
return fn(result.value);
}
// Usage: each step can fail, errors propagate automatically
async function processOrder(
orderId: string,
): Promise<Result<Receipt, OrderError>> {
const order = await fetchOrder(orderId);
if (!order.ok) return order;
const inventory = await checkInventory(order.value.items);
if (!inventory.ok) return inventory;
const payment = await chargePayment(order.value.total);
if (!payment.ok) return payment;
return ok(generateReceipt(order.value, payment.value));
}Each step has a typed error. The caller of processOrder sees a union of all possible errors and handles each explicitly.
Error Boundaries at the Edge
Keep try/catch at system boundaries. HTTP handlers, queue consumers, and CLI entry points are the right place. Everything inside is typed Results that propagate cleanly.
// API route handler — the only place with try/catch
export async function POST(req: Request) {
try {
const body = await req.json();
const result = await processOrder(body.orderId);
if (!result.ok) {
const statusMap: Record<OrderError, number> = {
NOT_FOUND: 404,
INVENTORY_UNAVAILABLE: 409,
PAYMENT_FAILED: 402,
DB_ERROR: 500,
};
return Response.json(
{ error: result.error },
{ status: statusMap[result.error] ?? 500 },
);
}
return Response.json(result.value, { status: 201 });
} catch (e) {
// Truly unexpected — log and fail
console.error("Unhandled error in POST /orders", e);
return Response.json({ error: "INTERNAL_ERROR" }, { status: 500 });
}
}Infrastructure errors (try/catch) stay at the boundary. Business logic errors (Result) are handled explicitly. The distinction makes the code easier to reason about and test.
Testing Error Paths
One underappreciated benefit of Result types: they make error paths trivially testable. No need to mock throw behavior — just return the appropriate error type.
// Testing is clean — no try/catch, no mock implementations that throw
describe("processOrder", () => {
it("returns INVENTORY_UNAVAILABLE when stock is depleted", async () => {
mockFetchOrder.mockResolvedValue(ok(sampleOrder));
mockCheckInventory.mockResolvedValue(err("INVENTORY_UNAVAILABLE" as const));
const result = await processOrder("order-123");
expect(result.ok).toBe(false);
expect(result.ok === false && result.error).toBe("INVENTORY_UNAVAILABLE");
});
});Compare this to testing code that throws — you need expect(...).rejects.toThrow(...), which loses type information and requires extra ceremony.
Key Takeaways
try/catchhides errors from the type system — useResult<T, E>for expected failures so the compiler tracks them- Discriminated unions give you zero-cost typed errors — no library required, no runtime overhead
- Reserve
throwfor genuine programmer bugs — if it's a recoverable condition, it should be aResult - Keep error boundaries at system edges — HTTP handlers and queue consumers are the right place for infrastructure
try/catch - Typed errors make testing easier — you can return error Results directly without mocking
throwbehavior - Compose Results with helpers — chaining fallible operations stays readable with a simple
andThenutility


