Type Safe Error Boundaries in TypeScript and React
AI generated
<T>
type
TypeScript · React · Error Handling
Type Safe Error Boundaries
generic fallbacks instead of any for rendering failures

The React error boundary interface internally works with the type unknown for caught errors, which often forces fallback components to fall back on any or unsafe casts. With generic type parameters and discriminated error types, error boundaries can be built that hand the caller concrete, typed error information.

17 min read componentDidCatch · Fallback Props · Generics React 18/19 · TypeScript 5.x

1. What error boundaries solve and where TypeScript fits in

An error boundary is a React component that catches errors during rendering, in lifecycle methods, and in constructors of its child components, instead of letting the entire component tree crash. Without an error boundary, a single unhandled error in a deeply nested component causes React to remove the entire visible tree, a behavior in place since React 16 to avoid inconsistent UI states.

The React interface for error boundaries dates from before widespread TypeScript adoption and is typed accordingly loosely: the method componentDidCatch(error: Error, errorInfo: ErrorInfo) does provide a concrete Error type, but everything that happens afterward in the fallback display is left to the implementation. This is exactly where type safety comes in: instead of passing the caught error through the fallback component as unknown or any, generic type parameters can be used to pass along concrete error information type safely.

The value of type safe error boundaries shows up especially in larger applications with several, differently specialized boundaries: one for data fetch errors, one for rendering errors in third party widgets, one for payment components. Each of these boundaries can inherit from a generic base type that takes the expected error type as a type parameter, so the fallback component knows exactly which fields are available on the error object.

2. Extending the error boundary interface type safely

React itself offers no hook replacement for error boundaries, they still have to be implemented as a class component, because componentDidCatch and getDerivedStateFromError only exist in the class API. For TypeScript that means: the base class React.Component<P, S> gets extended with a state that holds the caught error, typically as { hasError: boolean; error: Error | null }. This minimal signature is enough for a generic fallback display, but it loses all structure once several error kinds need to be handled differently.

A sensible first step is to formulate the state type as a generic parameter, so a concrete error boundary subclass can declare a more precise error type than Error, for example a custom DataFetchError class with additional fields such as statusCode. It matters that getDerivedStateFromError still receives unknown as its input type, because JavaScript technically allows throwing arbitrary values, not just Error instances. The type refinement therefore has to happen inside the method, not in the signature.

This restriction is not a TypeScript weakness, it reflects actual JavaScript semantics: throw "a string" is valid code, even if it rarely makes sense. A robust error boundary implementation therefore checks at runtime whether the caught value is actually an Error instance or a custom, expected error class before accessing specific fields.


import { Component, type ErrorInfo, type ReactNode } from "react";

// Base state shape shared by all boundaries in this app
interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback: (error: Error) => ReactNode;
}

class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  state: ErrorBoundaryState = { hasError: false, error: null };

  static getDerivedStateFromError(caught: unknown): ErrorBoundaryState {
    // JavaScript allows throwing anything, so this always starts as unknown
    const error = caught instanceof Error ? caught : new Error(String(caught));
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error("Boundary caught:", error, errorInfo.componentStack);
  }

  render(): ReactNode {
    if (this.state.hasError && this.state.error) {
      return this.props.fallback(this.state.error);
    }
    return this.props.children;
  }
}

3. Building a generic error boundary component type

The next step is to make the error boundary itself generic over the expected error type instead of hardcoding Error. This introduces a generic type parameter E extends Error, used both in the state and in the fallback props. The caller then instantiates the error boundary with a concrete error type, for example ErrorBoundary<ApiError>, and the fallback function receives the precise type directly, without a manual cast.

This generalization brings a noticeable advantage especially in codebases with several specialized error boundaries: a boundary around a payment component can be strictly typed to PaymentError, while a boundary around a third party widget stays generic at Error, because no more specific information can be expected there. The compiler prevents a mistyped fallback function from accidentally being bound to the wrong boundary.

A detail that is easy to overlook: TypeScript generics on class components work technically, but type inference in JSX usage is sometimes limited, especially in older TypeScript versions. From TypeScript 4.4 onward, inference for generic components in JSX works reliably enough to skip explicit type parameters in most cases, as long as the fallback prop itself is correctly typed.


import { Component, type ReactNode } from "react";

interface GenericErrorBoundaryProps<E extends Error> {
  children: ReactNode;
  fallback: (error: E) => ReactNode;
  // Type guard lets each boundary decide which errors it actually handles
  isExpectedError: (caught: unknown) => caught is E;
}

interface GenericErrorBoundaryState<E extends Error> {
  error: E | null;
}

class ErrorBoundary<E extends Error> extends Component<
  GenericErrorBoundaryProps<E>,
  GenericErrorBoundaryState<E>
> {
  state: GenericErrorBoundaryState<E> = { error: null };

  componentDidCatch(caught: unknown): void {
    if (this.props.isExpectedError(caught)) {
      this.setState({ error: caught });
    } else {
      throw caught; // let a parent boundary or React itself handle it
    }
  }

  render(): ReactNode {
    if (this.state.error) {
      return this.props.fallback(this.state.error);
    }
    return this.props.children;
  }
}

class PaymentError extends Error {
  constructor(message: string, readonly declineCode: string) {
    super(message);
    this.name = "PaymentError";
  }
}

function isPaymentError(caught: unknown): caught is PaymentError {
  return caught instanceof PaymentError;
}

4. Coupling fallback props to the error type type safely

Once the error boundary itself is generic, the same approach is worth applying to the fallback component too. Instead of a function that only accepts an error, a dedicated fallback component can receive props tailored exactly to the error type, such as retryable: boolean for a network error or declineCode: string for a payment error. This coupling makes the fallback UI itself testable and reusable, independent of the concrete error boundary that invokes it.

A proven pattern is to define the fallback props as their own generic type derived from the error type, rather than duplicating them manually. With a mapped type, a PaymentErrorFallbackProps type can be derived automatically from PaymentError, combining the relevant fields of the error with additional UI specific props such as onRetry: () => void. This derivation ensures a change to the error type automatically shows up in the fallback props type, without keeping two places manually in sync.

In practice, the value of this coupling shows up especially in code reviews: a reviewer immediately sees, from the type of the fallback component, which information is actually available in the failure case, without having to read the error boundary implementation itself. That substantially reduces cognitive load compared to a generic fallback: ReactNode prop that carries no information about the error context at all.


// Fallback props derived directly from the error type
type FallbackProps<E extends Error> = {
  error: E;
  onRetry: () => void;
};

function PaymentErrorFallback({ error, onRetry }: FallbackProps<PaymentError>) {
  return (
    <div role="alert">
      <p>Payment failed: {error.message}</p>
      <p>Decline code: {error.declineCode}</p>
      <button onClick={onRetry}>Try again</button>
    </div>
  );
}

// The boundary passes a bound retry handler into the fallback
function CheckoutSection() {
  const [attempt, setAttempt] = useState(0);
  return (
    <ErrorBoundary<PaymentError>
      isExpectedError={isPaymentError}
      fallback={(error) => (
        <PaymentErrorFallback error={error} onRetry={() => setAttempt((n) => n + 1)} />
      )}
    >
      <PaymentForm key={attempt} />
    </ErrorBoundary>
  );
}

function useState<T>(initial: T): [T, (updater: (prev: T) => T) => void] {
  // placeholder signature for illustration only
  return [initial, () => {}];
}
function PaymentForm() {
  return null;
}

5. Combining error boundaries with discriminated error types

A single error boundary that catches several different error kinds benefits strongly from a discriminated union as the error type, instead of several class hierarchies. Instead of running instanceof checks for each error kind separately, an error boundary converts the caught value into a typed union and delegates rendering to a switch statement in the fallback component, which, thanks to exhaustiveness checking, never forgets a variant.

This pattern connects the two concepts covered in related articles: the error boundary acts as a translation layer between the raw, untyped JavaScript exception and a clean discriminated union used throughout the rest of the application. The fallback component itself then no longer needs to know anything about React internals, it simply consumes a union like any other component would.

A practical benefit: if a new error kind is introduced later, the never based exhaustiveness check in the fallback component immediately reports that a new case is missing, exactly as with any other discriminated union processing. The error boundary itself does not need to be touched for this, as long as the translation function from the caught value into the union covers every case.


type RenderError =
  | { kind: "payment"; error: PaymentError }
  | { kind: "network"; statusCode: number }
  | { kind: "unknown"; raw: unknown };

function classifyCaughtValue(caught: unknown): RenderError {
  if (isPaymentError(caught)) {
    return { kind: "payment", error: caught };
  }
  if (caught instanceof Error && "statusCode" in caught) {
    return { kind: "network", statusCode: (caught as { statusCode: number }).statusCode };
  }
  return { kind: "unknown", raw: caught };
}

function renderFallback(classified: RenderError): ReactNode {
  switch (classified.kind) {
    case "payment":
      return <PaymentErrorFallback error={classified.error} onRetry={() => {}} />;
    case "network":
      return <p>Network error, status {classified.statusCode}</p>;
    case "unknown":
      return <p>Unexpected error occurred</p>;
  }
}

type ReactNode = unknown;

6. Async errors and error boundaries: what TypeScript does not catch

A common misconception, independent of TypeScript, is assuming an error boundary catches every error in its child tree. React error boundaries exclusively catch errors during rendering, not in event handlers, not in setTimeout callbacks, and not in asynchronous functions such as a fetch call inside a useEffect. This behavior is pure React runtime semantics and is neither enforced nor signaled by TypeScript, because a component's type carries no information about the execution context in which an error occurs.

For asynchronous errors, the combination of try/catch inside useEffect and local error state remains the right solution, often paired with the Result type pattern covered in a related article. A practical pattern is to manually re throw async errors into React's render cycle, by storing the error in state and then throwing it synchronously during the next render. This trick relies on the fact that an error thrown during rendering is actually caught by the surrounding error boundary.

Documenting this boundary clearly matters, because misplaced trust in error boundaries for async code leads to unhandled promise rejections that show up as a warning in the console but never put the UI into a defined error state. A central global handler for window.addEventListener("unhandledrejection", ...) can serve as an additional safety net, but it does not replace targeted, component level error handling.


// Re-throwing an async error during render so an Error Boundary can catch it
function useAsyncErrorRethrow(): (error: unknown) => void {
  const [, setState] = useState<unknown>();
  return (error: unknown) => {
    setState(() => {
      throw error; // thrown synchronously during the next render
    });
  };
}

function useState<T>(initial?: T): [T | undefined, (updater: () => never) => void] {
  return [initial, () => {}];
}

7. Testing error boundaries with type safe test utilities

Testing an error boundary requires deliberately throwing an error in a child component, which in a typed test environment justifies a small helper component generic enough to be reused with different error types. A type safe test component ThrowError<E extends Error> takes an error instance as a prop and throws it during rendering, so any error boundary can be tested against the matching error type.

An important aspect is pointing React Testing Library assertions at the actually rendered fallback UI, not at internal implementation details of the error boundary. The test therefore checks whether the expected fallback message appears in the DOM, not whether an internal state field was set correctly. This approach stays stable even if the internal implementation of the error boundary is refactored later.

A second important test case is the exhaustiveness check itself: a unit test simulating a new, not yet handled variant of the discriminated union should fail at compile time, not only at runtime. Since TypeScript already performs this check during compilation, a simple build or type check step in the CI pipeline is enough to reliably detect missing cases, even before a test is ever executed.


// Generic helper component for testing any Error Boundary
function ThrowError<E extends Error>({ error }: { error: E }): never {
  throw error;
}

// Example test with React Testing Library (Jest/Vitest syntax)
test("renders payment fallback with decline code", () => {
  render(
    <ErrorBoundary<PaymentError>
      isExpectedError={isPaymentError}
      fallback={(error) => (
        <PaymentErrorFallback error={error} onRetry={() => {}} />
      )}
    >
      <ThrowError error={new PaymentError("Card declined", "insufficient_funds")} />
    </ErrorBoundary>
  );

  expect(screen.getByText(/insufficient_funds/)).toBeInTheDocument();
});

function render(_node: unknown): void {}
const screen = { getByText: (_matcher: unknown) => ({ toBeInTheDocument: () => {} }) };
function test(_name: string, _fn: () => void): void {}
function expect(_value: unknown) {
  return { toBeInTheDocument: () => {} };
}

8. Common mistakes when using error boundaries

The most common mistake is typing the caught error as any to move quickly, then propagating that loose typing through the entire fallback chain. Once any shows up in the state of an error boundary, every downstream component loses its type safety, because TypeScript silently passes any through any number of function calls without ever reporting an error.


// WRONG: any defeats the entire purpose of a typed error boundary
interface LooseState {
  error: any; // silently propagates through every consumer
}

// WRONG: assuming Error Boundaries catch async errors
useEffect(() => {
  fetchData().catch((error) => {
    throw error; // does NOT get caught by a surrounding Error Boundary
  });
}, []);

// RIGHT: convert the async failure into local state, then rethrow during render
useEffect(() => {
  fetchData().catch((error: unknown) => {
    setAsyncError(error); // triggers a synchronous throw on next render
  });
}, []);

function useEffect(_fn: () => void, _deps: unknown[]): void {}
function fetchData(): Promise<void> {
  return Promise.resolve();
}
function setAsyncError(_error: unknown): void {}

A second widespread mistake is placing a single, global error boundary around the entire application instead of placing fine grained boundaries around individual sections. That causes an error in an unimportant widget to replace the entire page with a generic fallback message, instead of isolating only the affected section. Several, deliberately placed error boundaries with specific error types allow for much better error isolation.

9. Error boundary strategies in comparison

The table below compares different strategies for typing error boundaries against practically relevant criteria.

Criterion any in State Fixed Error Type Generic Boundary with Union
Type safety in fallback None Only for one error type Complete, per variant
Exhaustiveness checking Not possible Not relevant Yes, with switch and never
Reusability High, but unsafe Low High and safe
Implementation effort Low Low Moderate
Suitable for async errors No No Only with re throw pattern

For small applications with few error sources, a fixed error type is usually enough. Once several clearly distinguishable error kinds appear across different parts of the application, the generic error boundary with a discriminated union pays off, since it offers exhaustiveness checking and reusability at the same time, without sacrificing type safety anywhere along the chain.

Mironsoft

React and TypeScript architecture for Hyva and Node.js frontends

Error boundaries that actually help instead of just hiding?

We build generic, type safe error boundaries with targeted fallback components and set up the right testing strategy for your React or Hyva frontend.

Boundary Design

Designing generic error boundaries with discriminated error types

Async Error Handling

Re throw pattern and global handlers for unhandled rejections

Testing Setup

Building type safe test components for error boundaries

10. Summary

Type safe error boundaries replace the loose Error or any typing of the classic React interface with generic type parameters that make the expected error type explicit. A type guard decides which error kinds a concrete error boundary actually handles, while unknown errors get passed up to a parent boundary. Fallback props derived directly from the error type make the fallback UI itself testable and independent of the internal boundary implementation.

What matters is the clear boundary: error boundaries exclusively catch errors during rendering, never in event handlers or asynchronous callbacks. For asynchronous errors, a re throw pattern combined with local state remains the right solution. Whoever knows this boundary and uses discriminated unions for several error kinds in a single error boundary gets exhaustiveness checking and testable fallback components at the same time.

Type Safe Error Boundaries, the essentials at a glance

Generic Type Parameter

E extends Error makes the expected error type explicit instead of passing along any or unknown.

Fallback Props

Derived directly from the error type, testable and independent of the boundary implementation.

Async Boundary

Error boundaries only catch rendering errors, async errors need a re throw pattern.

Discriminated Unions

Model several error kinds in one boundary via a union with exhaustiveness checking.

11. FAQ: Type Safe Error Boundaries

1What is an error boundary?
A class component that catches rendering errors and shows a fallback UI instead of crashing the whole tree.
2Why is componentDidCatch loosely typed?
JavaScript allows throwing arbitrary values, so the input stays unknown and must be refined at runtime.
3How do I make it generic?
With a type parameter E extends Error plus a type guard that selects the expected errors.
4Do they catch async errors?
No, only rendering errors. Async errors need a re throw pattern.
5What is the re throw pattern?
The error gets stored in state and re thrown synchronously on the next render.
6How do I derive fallback props?
Via a generic FallbackProps type derived from the error type.
7One or several boundaries?
Several, deliberately placed boundaries isolate errors better than a single global one.
8How do I test it?
With a generic ThrowError test component and assertions against the rendered fallback UI.
9Why is any a problem?
any propagates unchecked through the entire fallback chain and defeats all type safety.
10Several error kinds at once?
Yes, via translating into a discriminated union with switch and exhaustiveness checking.