TypeScript Error Handling Patterns That Actually Work in Production
I have reviewed hundreds of TypeScript codebases over the past three years, and error handling is consistently the weakest area. Most developers fall into one of two traps: either they catch everything and swallow errors silently, or they let exceptions bubble up uncaught and crash the application in unexpected places.
Neither approach works. Here are the patterns I have seen work well in production TypeScript applications.
The Problem With Try-Catch Everywhere
The most common anti-pattern I see is this: wrap every function body in try-catch, log the error, and return undefined or null. This creates several problems.
First, it hides bugs. When everything is caught and logged, errors become noise in your logging system. You stop checking logs because there are too many "errors" that are actually expected conditions (like a user not found in the database). The real bugs hide among the noise.
Second, it forces null checks everywhere. When any function might return undefined "because something went wrong," every caller needs to check for null. This spreads complexity throughout the codebase and defeats the purpose of TypeScript's type system.
Third, it makes debugging harder. When an error is caught at the wrong level, you lose the original stack trace. By the time you see "Cannot read property name of undefined" in production, the original database error that caused it is long gone.
Pattern 1: Result Types for Expected Failures
For operations that can fail in expected ways (user not found, validation error, external service timeout), use a Result type instead of exceptions:
Define a type that explicitly represents success or failure. The caller is forced by the type system to handle both cases. No try-catch needed, no null checks, no swallowed errors.
In practice this looks like: a function that queries the database returns either the data or a typed error describing what went wrong. The caller pattern-matches on the result and handles each case explicitly. TypeScript's exhaustive checking ensures you handle all error variants.
We use this pattern extensively in our API routes. A request handler might call three services, each returning a Result. If any returns an error, we map it to the appropriate HTTP status code and error response. No exceptions are thrown, no try-catch blocks are needed, and the error handling logic is explicit and visible.
Pattern 2: Error Boundaries at System Edges
Exceptions should be caught at system boundaries, not sprinkled throughout business logic. System boundaries include: HTTP request handlers (catch and return appropriate error responses), message queue consumers (catch, log, and nack/retry the message), scheduled job runners (catch, log, alert, and continue with the next job).
At these boundaries, use a single try-catch that logs the full error with context, returns an appropriate response, and triggers alerting for unexpected errors. Everything between system boundaries should let exceptions propagate naturally.
This means your business logic stays clean. Functions call other functions, errors propagate up the call stack, and the boundary handler deals with them uniformly. You get clear stack traces, no error swallowing, and consistent error responses.
Pattern 3: Custom Error Classes With Context
When you do throw exceptions (for truly unexpected errors or programming bugs), throw custom error classes that carry context:
Create a base AppError class that includes a machine-readable code, a human-readable message, and optional metadata. Extend it for specific error categories: DatabaseError, ValidationError, ExternalServiceError, AuthenticationError.
The code field lets boundary handlers map errors to HTTP status codes without parsing message strings. The metadata field carries context (which record was not found, which service timed out, what validation failed) for logging and debugging.
Pattern 4: Fail Fast for Programmer Errors
Not all errors should be handled gracefully. Programmer errors — calling a function with invalid arguments, accessing a null value that should never be null, reaching a code path that should be unreachable — should crash loudly and immediately.
Use assertion functions: function assertDefined
The distinction is crucial. Expected failures (network timeout, user not found) are handled with Result types. Unexpected failures (null where it should not be null, invalid state) crash with assertions. This keeps your error handling clean and your debugging fast.
Pattern 5: Typed Error Channels for Async Operations
For async operations that can fail in multiple ways, define an error channel type that enumerates all possible failure modes. Each failure mode carries its own metadata type.
This is particularly useful for operations that interact with external services. A payment processing function might fail due to: insufficient funds, expired card, network timeout, rate limit exceeded, or fraud detection. Each failure requires different handling (retry for network timeout, alert for fraud, user message for insufficient funds).
By typing the error channel explicitly, you ensure the caller handles each failure mode appropriately. TypeScript's type narrowing makes the handling code clean and safe.
Pattern 6: Graceful Degradation Over Hard Failure
For non-critical operations, prefer graceful degradation over failing the entire request. If the recommendation engine times out, show the page without recommendations. If the analytics event fails to send, continue without blocking.
Implement this with a "fire and forget" wrapper that catches errors from non-critical operations, logs them for later investigation, and returns a default value. Critical operations still throw and propagate normally.
We use this pattern in our tool pages. If fetching related tools fails, the page still renders with the primary tool content. If fetching user review counts fails, we show the tool without the review badge. The page always works — it might occasionally have slightly less information.
Putting It Together
In a typical request flow, errors are handled at three levels:
Business logic uses Result types for expected failures and lets unexpected errors propagate. Service layer catches external failures and wraps them in typed errors with context. Request handler (the boundary) catches everything, logs with full context, and returns appropriate HTTP responses.
This gives you: type-safe error handling where it matters, clean stack traces for debugging, consistent error responses for clients, and no swallowed errors hiding bugs.
The investment in proper error handling patterns pays dividends in production stability and debugging speed. Every team I have worked with that adopted these patterns reported fewer production incidents and faster resolution times.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.