try {
await fetchProfile();
} catch (error) {
// error is unknown
}
TypeScript hands you a caught value as unknown, and a function’s type says nothing about what it may throw. Whether a failure travels as an exception or a return value is invisible from the call site, which is why the question comes up again on every function.
Rather than force every failure into one mechanism, we can choose the representation that matches its meaning.
JavaScript exceptions
JavaScript raises an exception with throw. By convention we throw an Error, but the language permits any value, including a string or number. That is why TypeScript treats a caught value as unknown.
function fail() {
throw new Error("Something went wrong");
}
try {
fail();
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
}
}
Throwing transfers control to the first matching catch on the call stack. If nothing catches it, the runtime or framework handles it as an uncaught exception.
Preserving the cause
Sometimes a low-level error needs more useful context. Pass the original value as cause when wrapping it so that the underlying failure is not lost.
async function loadProfile() {
try {
return await fetchProfile();
} catch (error) {
throw new Error("Could not load the profile", {
cause: error,
});
}
}
Browsers and logging services display cause differently. Avoid depending on one console format; report it as structured data when that context matters.
Errors in React components
An Error Boundary can catch errors thrown while its descendants render and replace that part of the UI with a fallback. It does not automatically catch errors thrown later in event handlers or arbitrary asynchronous callbacks.
React still requires a class component to define an Error Boundary directly. A library such as react-error-boundary is a practical alternative.
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { AlbumDetail } from "./AlbumDetail";
import { FailedToLoad } from "./FailedToLoad";
import { Loading } from "./Loading";
export function Page() {
return (
<ErrorBoundary fallback={<FailedToLoad />}>
<Suspense fallback={<Loading />}>
<AlbumDetail />
</Suspense>
</ErrorBoundary>
);
}
Suspense owns the loading UI, while the Error Boundary owns the failure UI. This works when a framework or a Suspense-enabled data source, such as one read with use, communicates promises and errors to React. An ordinary fetch inside useEffect does not make Suspense manage loading for you.
Next.js App Router
In the App Router, placing error.tsx in a route segment wraps that segment in an Error Boundary. Its reset function attempts to render the failed segment again.
"use client";
import { useEffect } from "react";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
reportError(error);
}, [error]);
return (
<div>
<p>We could not load this content.</p>
<button type="button" onClick={reset}>
Try again
</button>
</div>
);
}
Expected failures such as invalid form input or a missing API record should generally be returned and rendered as normal state. An Error Boundary is not a replacement for an ordinary branch in the UI.
Representing failures in functions
Ask whether the caller should make a local decision or whether normal execution cannot continue.
Narrow caught values safely
Narrow a caught value before using it. A small helper can normalize the message when that is all the caller needs.
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown error";
}
Use a library-provided type guard, such as the isAxiosError helper from Axios, when one exists. Kent C. Dodds covers the narrowing itself in more depth.
Catching a failure is not the same as resolving it: recover, tell the user, add context and rethrow, or report it to monitoring at the layer that owns that responsibility.
Return expected failures
Expected product outcomes, such as validation errors or an out-of-stock item, fit well in a discriminated union. The type then requires the caller to handle the known alternatives.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type SaveProfileError =
| { type: "invalid-name"; message: string }
| { type: "conflict"; message: string };
function saveProfile(): Result<Profile, SaveProfileError> {
// Return the appropriate result.
}
Unlike the unknown from the opening, the ways this can fail are visible in the type. The caller branches on error.type and chooses the right UI or recovery path.
Throw unexpected failures
A lost connection, malformed response, or impossible internal state may prevent the current operation from continuing. Throwing is appropriate when the stack should unwind and several layers should converge on one recovery boundary.
If every expected outcome becomes an exception, ordinary control flow becomes difficult to see. If every unexpected fault becomes a returned value, it can be ignored while execution continues. The distinction matters more than consistency for its own sake.
Closing note
My working rule is:
- Return expected failures that the user or caller can recover from.
- Throw unexpected failures that make normal execution impossible.
- Isolate render failures with an Error Boundary and provide a recovery path.
Errors do not all have to look the same. Preserve the meaning of a failure and hand it to the layer that can take responsibility for it.