Bỏ qua để vào nội dung chính
Modern Error Handling in TypeScript: Beyond try-catch

Modern Error Handling in TypeScript: Beyond try-catch

Bởi Benjamin Hayes
13 thg 3, 20266 phút đọc

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 _tag discriminators for exhaustive switch statements
  • Reserve try-catch for 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan