TypeScript Error Handling in Production: Result Types, Error Boundaries, and Graceful Degradation
try-catch gets you started but it fails at scale. This guide covers Result types, discriminated unions, React error boundaries, API error design, and observability integration for TypeScript applications that need to stay alive under real conditions.
Every TypeScript codebase I have worked on at scale has the same problem: a thin layer of try-catch at the edges, and then optimistic, unguarded code everywhere in between. It works until it does not. A third-party SDK throws an undocumented error shape. A database call times out inside a loop. A Promise rejection slips through because someone forgot await. The app goes down, or worse, it silently corrupts state.
This is not a discipline problem. It is a tooling problem. try-catch is an extremely blunt instrument. It separates the point where errors are produced from the point where they are handled, with no type-system enforcement in between. TypeScript knows nothing about what a catch block contains. You get unknown, and you are on your own.
There are better patterns. They require some upfront design, but they pay off in systems that degrade predictably, log accurately, and do not wake anyone up at 3am for a recoverable condition.
Why try-catch Falls Short
Consider a typical service call:
async function getUserProfile(userId: string) {
try {
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const preferences = await fetchPreferences(user.id);
return { user, preferences };
} catch (error) {
console.error("Failed to get user profile", error);
throw error;
}
}
Three problems in twelve lines. First, if fetchPreferences throws, the error message “Failed to get user profile” tells you nothing about whether the database or the preferences service failed. Second, the throw error at the bottom forces every caller to wrap this in its own try-catch, which most will not. Third, TypeScript types the catch binding as unknown, so any property access on error is a runtime gamble.
Now multiply this across a hundred service functions. You get a codebase where errors propagate upward through untyped throws, get swallowed in some places and re-thrown in others, and produce log noise that is nearly impossible to correlate.
The Result Type Pattern
Rust’s Result<T, E> and Go’s (value, error) idiom address this differently: they make errors part of the return type. The function signature tells you it can fail. The caller is forced to handle both cases. No invisible control flow.
In TypeScript, you model this with a discriminated union:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E>(error: E): Err<E> {
return { ok: false, error };
}
Now rewrite the service function:
type UserProfileError =
| { code: "USER_NOT_FOUND"; userId: string }
| { code: "PREFERENCES_UNAVAILABLE"; userId: string; cause: unknown }
| { code: "DATABASE_ERROR"; cause: unknown };
async function getUserProfile(
userId: string
): Promise<Result<{ user: User; preferences: UserPreferences }, UserProfileError>> {
const userResult = await queryUser(userId);
if (!userResult.ok) return userResult;
const prefsResult = await fetchPreferences(userResult.value.id);
if (!prefsResult.ok) {
return err({ code: "PREFERENCES_UNAVAILABLE", userId, cause: prefsResult.error });
}
return ok({ user: userResult.value, preferences: prefsResult.value });
}
The return type is now honest. Callers cannot ignore the error case because they have to check result.ok to access result.value. TypeScript’s exhaustive narrowing catches unhandled branches at compile time.
Libraries vs. Hand-Rolled
You have three options: hand-roll, use neverthrow, or use ts-results. Here is the honest comparison.
| Criterion | Hand-rolled | neverthrow | ts-results |
|---|---|---|---|
| Bundle size | ~0kb | ~3kb | ~2kb |
| API ergonomics | Your choice | Fluent chains | Similar to Rust |
map / flatMap | You implement | Built-in | Built-in |
| Async support | You implement | ResultAsync | Manual |
| Learning curve | Low | Low-medium | Low |
| Maintenance burden | You own it | External dep | External dep |
neverthrow is the strongest choice if you want combinator-style chaining. Its ResultAsync wrapper makes async pipelines clean:
import { ResultAsync, errAsync, okAsync } from "neverthrow";
function queryUser(userId: string): ResultAsync<User, UserProfileError> {
return ResultAsync.fromPromise(
db.query("SELECT * FROM users WHERE id = $1", [userId]),
(e): UserProfileError => ({ code: "DATABASE_ERROR", cause: e })
).andThen((rows) =>
rows.length === 0
? errAsync({ code: "USER_NOT_FOUND", userId })
: okAsync(rows[0] as User)
);
}
The .andThen chain composes cleanly without nesting. If you are already using fp-ts or effect, those ecosystems have their own result types that integrate with their broader abstractions. For most production TypeScript services, neverthrow is the practical default.
Hand-rolling is fine if your team prefers zero dependencies and you are willing to implement the combinators you need. The interface is not complicated. The risk is that teams under-invest in the helpers and fall back to manual if (!result.ok) chains everywhere, which gets verbose fast.
Typed Error Hierarchies
The power of the pattern multiplies when you define error types at the domain level, not the function level. Group errors by the layer they originate from:
// Domain errors: these are expected business conditions
type DomainError =
| { code: "USER_NOT_FOUND"; userId: string }
| { code: "INSUFFICIENT_FUNDS"; accountId: string; required: number; available: number }
| { code: "PLAN_LIMIT_EXCEEDED"; planId: string; limit: number };
// Infrastructure errors: unexpected conditions from external systems
type InfraError =
| { code: "DATABASE_TIMEOUT"; queryName: string; durationMs: number }
| { code: "EXTERNAL_API_ERROR"; service: string; status: number; body: string }
| { code: "CACHE_MISS"; key: string };
type AppError = DomainError | InfraError;
This separation matters for observability. A USER_NOT_FOUND error should not fire a PagerDuty alert. A DATABASE_TIMEOUT probably should. When your error codes carry semantic meaning, routing them to the right handler is mechanical.
React Error Boundaries
On the frontend, unhandled exceptions in render functions cause the entire React tree to unmount. Error boundaries let you contain that damage.
import React, { Component, type ReactNode } from "react";
interface Props {
fallback: ReactNode;
onError?: (error: Error, info: React.ErrorInfo) => void;
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
this.props.onError?.(error, info);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
Error boundaries only catch errors that happen during render, in lifecycle methods, and in constructors of child components. They do not catch errors in event handlers, async code, or server-side rendering. That means you still need explicit handling in useEffect and event callbacks.
A good production setup uses multiple error boundaries at different granularities: one at the app root to prevent a full white screen, and smaller ones around independent widgets that can fail without taking down the page:
export function Dashboard() {
return (
<ErrorBoundary fallback={<AppCrashFallback />} onError={reportToMonitoring}>
<Sidebar />
<ErrorBoundary fallback={<WidgetError name="analytics" />} onError={reportToMonitoring}>
<AnalyticsWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="activity" />} onError={reportToMonitoring}>
<ActivityFeed />
</ErrorBoundary>
</ErrorBoundary>
);
}
If AnalyticsWidget crashes, the sidebar and activity feed keep rendering. The user sees a degraded page, not a broken one.
API Error Response Design
Consistent error responses across your API surface make client-side handling predictable. A schema worth adopting:
interface ApiError {
code: string; // machine-readable, stable across versions
message: string; // human-readable, for developers
details?: unknown; // structured context for the specific error
requestId: string; // correlates to your logs
}
interface ApiResponse<T> {
data?: T;
error?: ApiError;
}
On the server side (using Hono as an example), you map your domain errors to HTTP responses in one place:
function domainErrorToResponse(error: AppError): { status: number; body: ApiError } {
const requestId = crypto.randomUUID();
switch (error.code) {
case "USER_NOT_FOUND":
return {
status: 404,
body: { code: error.code, message: "User not found", requestId },
};
case "INSUFFICIENT_FUNDS":
return {
status: 422,
body: {
code: error.code,
message: "Insufficient funds for this operation",
details: { required: error.required, available: error.available },
requestId,
},
};
case "DATABASE_TIMEOUT":
return {
status: 503,
body: { code: "SERVICE_UNAVAILABLE", message: "Try again shortly", requestId },
};
default: {
const _exhaustive: never = error;
return {
status: 500,
body: { code: "INTERNAL_ERROR", message: "An unexpected error occurred", requestId },
};
}
}
}
The never check at the bottom is load-bearing: if you add a new error variant and forget to handle it here, TypeScript will fail the build. This is the compiler enforcing completeness, which is exactly what you want in a production switch statement.
Graceful Degradation Strategies
Not every failure should surface to the user. For non-critical features, you can degrade gracefully by substituting a safe default:
async function getPageData(userId: string) {
const [profileResult, recommendationsResult] = await Promise.allSettled([
getUserProfile(userId),
getRecommendations(userId),
]);
const profile =
profileResult.status === "fulfilled" && profileResult.value.ok
? profileResult.value.value
: null;
const recommendations =
recommendationsResult.status === "fulfilled" && recommendationsResult.value.ok
? recommendationsResult.value.value
: []; // degrade to empty list, not an error page
if (!profile) {
// core data missing, surface the error
return err({ code: "PROFILE_UNAVAILABLE" });
}
return ok({ profile, recommendations });
}
Promise.allSettled is the right tool here. Promise.all fails fast on any rejection, which is fine when all data is required but wrong when some data is optional. The pattern above treats recommendations as optional: a fetch failure means an empty list, not a broken page.
For retry logic on transient failures, keep it simple and narrow:
async function withRetry<T, E>(
fn: () => Promise<Result<T, E>>,
isRetryable: (error: E) => boolean,
maxAttempts = 3,
delayMs = 200
): Promise<Result<T, E>> {
let lastResult: Result<T, E> | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await fn();
if (result.ok || !isRetryable(result.error)) {
return result;
}
lastResult = result;
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** attempt));
}
}
return lastResult!;
}
The isRetryable predicate keeps retry scope tight. Retrying a USER_NOT_FOUND error wastes time and adds noise. Retrying a DATABASE_TIMEOUT is reasonable.
Observability Integration
Error codes become actionable when they are structured log fields, not string interpolations. With Pino:
import pino from "pino";
const logger = pino({ level: "info" });
function logAppError(error: AppError, context: Record<string, unknown> = {}) {
const isInfra = "DATABASE_TIMEOUT" === error.code || "EXTERNAL_API_ERROR" === error.code;
const level = isInfra ? "error" : "warn";
logger[level]({
errorCode: error.code,
...context,
...error,
}, "Application error");
}
When errorCode is a structured field, your log aggregator (Datadog, Grafana Loki, CloudWatch Insights) can alert on specific codes, build dashboards by error type, and correlate spikes with deployments. If you are logging console.error("Failed:", error) with a stringified error object, you cannot do any of that.
For frontend error monitoring, you pass structured context to Sentry or a similar sink:
import * as Sentry from "@sentry/react";
function reportToMonitoring(error: Error, info: React.ErrorInfo) {
Sentry.withScope((scope) => {
scope.setExtras({ componentStack: info.componentStack });
Sentry.captureException(error);
});
}
Pair this with the onError prop on your error boundaries, and every render crash flows through a single reporting path.
When Exceptions Are Still the Right Choice
The Result type pattern has a cost: it is verbose, and it forces every intermediate function to propagate errors explicitly. That is usually what you want, but not always.
Use exceptions for:
- Programmer errors: invalid function arguments, violated invariants, unreachable code paths. These represent bugs, not recoverable conditions. An
Errorthrown from a validation function is appropriate. AResultwould imply the caller should handle “invalid argument” as a business case, which is wrong. - Middleware and framework boundaries: Express error handlers, React error boundaries, and Next.js
error.tsxfiles are designed to catch thrown exceptions. At these integration points, converting aResultto a throw is often cleaner than threading the result type up through framework abstractions. - Tests: assertion failures should throw. Do not wrap test assertions in Result types.
The clearest heuristic: if the caller can reasonably be expected to recover from the condition and continue, use Result. If the condition means the caller made a mistake or the system is in an unrecoverable state, throw.
Putting It Together
The patterns here compose: domain-typed errors feed into consistent API responses, which map to structured log fields, which power dashboards and alerts. Error boundaries on the frontend contain render failures and report them through the same monitoring path. Retry logic handles transient infra errors before they ever reach the user.
The Result type is the connective tissue. Once error conditions are part of function signatures, the compiler enforces handling at every layer. You stop discovering unhandled error paths in production logs and start catching them in code review.
None of this requires a framework or a large dependency. A hundred lines of utility types and two or three helper functions is enough infrastructure to change how your whole codebase handles failure. The investment is in discipline and design, not in packages.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.