Modern Error Handling in TypeScript: Beyond try-catch
try-catch is blunt and loses type information. Learn Result types, exhaustive error matching, and typed error boundaries for robust TypeScript applications.
We've all been there: a try-catch block wrapping half a function, the catch clause receiving an unknown error, and a vague console.error that helps no one in production. TypeScript deserves better error handling patterns — ones that make errors visible in the type system instead of hiding them behind thrown exceptions.
The Problem with try-catch
Thrown exceptions are invisible to TypeScript's type system. A function signature tells you nothing about what can go wrong:
// What errors can this throw? TypeScript has no idea.
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Not found"); // invisible to callers
return res.json();
}
// Callers must guess what to catch
try {
const user = await fetchUser("123");
} catch (e) {
// e is unknown — what type of error? Network? 404? Parse failure?
// We're back to runtime guessing
}
Pattern 1: The Result Type
Inspired by Rust and functional programming, a Result type encodes success and failure directly in the return type:
type Result<T, E = Error> =
| { 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 };
}
// Now errors are VISIBLE in the type signature
type FetchUserError =
| { code: "NOT_FOUND"; userId: string }
| { code: "NETWORK"; message: string }
| { code: "PARSE"; raw: string };
async function fetchUser(id: string): Promise<Result<User, FetchUserError>> {
try {
const res = await fetch(`/api/users/${id}`);
if (res.status === 404) {
return err({ code: "NOT_FOUND", userId: id });
}
if (!res.ok) {
return err({ code: "NETWORK", message: res.statusText });
}
const data = await res.json();
return ok(data as User);
} catch {
return err({ code: "NETWORK", message: "Connection failed" });
}
}
// Callers get exhaustive error handling
const result = await fetchUser("123");
if (!result.ok) {
switch (result.error.code) {
case "NOT_FOUND":
return notFound(); // Next.js 404
case "NETWORK":
return <ErrorPage message={result.error.message} />;
case "PARSE":
console.error("Bad response:", result.error.raw);
return <ErrorPage message="Invalid data" />;
}
}
// result.value is User here — fully narrowed
Pattern 2: Tagged Error Classes
For teams that prefer classes over plain objects, tagged error classes combine nicely with discriminated unions:
class NotFoundError {
readonly _tag = "NotFoundError" as const;
constructor(public resource: string, public id: string) {}
}
class ValidationError {
readonly _tag = "ValidationError" as const;
constructor(public field: string, public message: string) {}
}
class NetworkError {
readonly _tag = "NetworkError" as const;
constructor(public statusCode: number) {}
}
type AppError = NotFoundError | ValidationError | NetworkError;
function handleError(error: AppError): string {
switch (error._tag) {
case "NotFoundError":
return `${error.resource} ${error.id} not found`;
case "ValidationError":
return `Invalid ${error.field}: ${error.message}`;
case "NetworkError":
return `Request failed with status ${error.statusCode}`;
}
// TypeScript ensures exhaustiveness — no default needed
}
Pattern 3: Typed Error Boundaries in React
React Error Boundaries can be typed to handle specific error types differently in your Next.js app:
"use client";
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
export class TypedErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}
// Usage in a Next.js layout
<TypedErrorBoundary
fallback={(error, reset) => (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<DashboardContent />
</TypedErrorBoundary>
Takeaways
- Result types make errors visible in function signatures — callers can't ignore them
- Use tagged error classes with
_tagdiscriminators for exhaustive switch statements - Reserve
try-catchfor truly unexpected failures at system boundaries - In React, use typed Error Boundaries for graceful UI recovery
- The goal isn't to eliminate exceptions entirely — it's to make expected failure modes explicit
Moving error handling into the type system is one of the highest-leverage improvements you can make to a TypeScript codebase. Start with your most critical data-fetching functions and work outward.
Admin
Cal.com
Open source scheduling — tự host booking system, thay thế Calendly. Free & privacy-first.
Bình luận (0)
Đăng nhập để bình luận
Chưa có bình luận nào. Hãy là người đầu tiên!