TypeScript Error Handling: 4 Patterns Tested Against Production Failures
A comparison of try/catch with `unknown`, the Go-inspried tuple pattern, neverthrow's Result type, and TypeScript-zod safeParse. Which one actually survives unhandled rejections, null pointer bugs, and silent data corruption in production?
TypeScript’s type system is excellent at catching null references and shape mismatches at compile time. But error handling — the runtime shape of throw — remains a blind spot. The catch clause defaults to any, and TypeScript offers no mechanism to declare which errors a function throws [1].
This matters in production. A study of 200 JavaScript/TypeScript production outages found that unhandled errors and improper catch blocks contributed to 34% of critical incidents tracked in root-cause analyses [2]. The problem isn’t that errors happen — it’s that the patterns we use to handle them each have specific failure modes that only surface under load.
This post tests four error handling patterns against real production failure scenarios, building evidence for which ones you should standardize on.
The four patterns defined
Each function returns a User object from a mock database lookup. The “error” is a UserNotFoundError that the database layer might throw.
Pattern A: Untyped try/catch
The default. TypeScript allows catch (e) with no annotation, defaulting to any:
async function getUser(id: string): Promise<User> {
return db.users.findOrThrow(id);
}
async function handleRequest(id: string) {
try {
const user = await getUser(id);
return { status: 200, body: user };
} catch (e) {
// e is `any` — no type safety at all
Sentry.captureException(e);
return { status: 404, body: { error: "not found" } };
}
}
Vulnerability: Swallows TypeError, SyntaxError, and accidental throws from inside the try block with the same handler. A ReferenceError from a typo inside try gets reported as “user not found.”
Pattern B: Unknown catch with type guard
TypeScript 4.0+ allows catch (e: unknown), forcing narrowing:
function isUserNotFoundError(e: unknown): e is UserNotFoundError {
return e instanceof UserNotFoundError;
}
async function handleRequest(id: string) {
try {
const user = await getUser(id);
return { status: 200, body: user };
} catch (e: unknown) {
if (isUserNotFoundError(e)) {
return { status: 404, body: { error: "not found" } };
}
// Re-throw anything we don't recognize
throw e;
}
}
Advantage: Prevents swallowing unexpected errors. The else branch re-throws, giving upper layers a chance to handle TypeErrors separately.
Pattern C: Go-inspried tuple (catchError helper)
Wraps the promise in a helper that returns [Error | undefined, T | undefined]:
async function catchError<T>(
promise: Promise<T>
): Promise<[Error | undefined, T | undefined]> {
try {
const data = await promise;
return [undefined, data];
} catch (error) {
return [error as Error, undefined];
}
}
async function handleRequest(id: string) {
const [error, user] = await catchError(getUser(id));
if (error) {
return { status: 404, body: { error: error.message } };
}
return { status: 200, body: user! };
}
Tradeoff: Ergonomic for individual async calls. Breaks down with multiple sequential operations — you end up with a pyramid of if (error) checks.
Pattern D: Result type (neverthrow)
Returns an explicit Result<User, UserNotFoundError> that must be unwrapped:
import { ok, err, type Result } from "neverthrow";
async function getUser(id: string): Promise<Result<User, UserNotFoundError>> {
try {
const user = await db.users.findOrThrow(id);
return ok(user);
} catch (e) {
if (e instanceof UserNotFoundError) {
return err(e);
}
throw e; // Unexpected errors still propagate
}
}
async function handleRequest(id: string) {
const result = await getUser(id);
return result.match(
(user) => ({ status: 200, body: user }),
(error) => ({ status: 404, body: { error: error.message } })
);
}
Advantage: The return type explicitly documents Result<User, UserNotFoundError>. Callers cannot access .value without checking .isOk() — the type system enforces error handling [3].
Test 1: Swallowing unrelated errors
Each pattern is tested against a getUser that throws TypeError (a programming mistake) inside the try block:
// Simulating a ReferenceError from a typo
async function getUser(id: string) {
const result = await db.users.findOrThrow(id);
// Oops — typo
console.log(resut); // ReferenceError: resut is not defined
return result;
}
| Pattern | Behavior | Outcome |
|---|---|---|
| A (catch any) | Catches both UserNotFoundError and ReferenceError, returns 404 | ✅ Bad — masks a programming error as a user error |
| B (catch unknown + guard) | ReferenceError is not UserNotFoundError, re-throws | ✅ Correct propagation |
| C (tuple) | Returns [Error, undefined] — error is truthy, returns 404 | ❌ Bad — same masking problem as A |
| D (Result) | Uncaught ReferenceError propagates out of getUser before ok/err | ✅ Correct propagation |
Patterns A and C hide programming mistakes. If you use these patterns, you must have a separate interceptor at the framework level (Express error middleware, Next.js error.tsx) that catches these and surfaces them in your monitoring — but the patterns themselves don’t force it.
Patterns B and D propagate unexpected errors by design, preserving the semantic difference between “expected failure” and “programming error.”
Test 2: TypeScript compiler enforcement
The compiler’s ability to require error handling varies dramatically:
// Pattern A — TypeScript allows this:
try {
const user = await getUser(id);
display(user);
} catch (e) {
// Entirely empty catch — wastes the error
}
// Pattern B — Same, because catch returns nothing:
try {
const user = await getUser(id);
} catch (e: unknown) {
// TypeScript doesn't enforce anything here either
}
// Pattern D — TypeScript won't compile:
const result: Result<User, UserNotFoundError> = await getUser(id);
console.log(result); // Error! Must check .isOk() first
user = result.value; // Error! Can't access .value on Result
| Pattern | Forces error handling? | Missing handler caught at compile time? |
|---|---|---|
| A | No | No |
| B | No | No |
| C | Partial — destructure | No (can destructure without checking) |
| D | Yes | Yes — .value requires .isOk() |
The neverthrow + eslint-plugin-neverthrow combination makes unhandled results a lint error (must-use-result rule). This is the only pattern that enforces handling at both the type level and the lint level [4].
Test 3: Error discrimination (multiple error types)
Real APIs have more than one failure mode. Pattern A handles a multi-error case like:
// In the database layer
class NotFoundError extends Error { code = "NOT_FOUND"; }
class RateLimitError extends Error { retryAfter: number; code = "RATE_LIMIT"; }
class DatabaseError extends Error { cause: Error; code = "DB_FAILURE"; }
Pattern A requires instanceof chains in the catch block. Pattern C needs a discriminated field on the Error. Pattern D is the only one that encodes the error type at the type level:
type UserError = NotFoundError | RateLimitError | DatabaseError;
async function getUser(id: string): Promise<Result<User, UserError>> { ... }
const result = await getUser(id);
result.match(
(user) => display(user),
(error) => {
switch (error.code) { // TypeScript narrows error.code:
case "NOT_FOUND": return 404;
case "RATE_LIMIT": return 429;
case "DB_FAILURE": return 500;
}
}
);
This gives you exhaustive checking via the error.code discriminant. If you add a new error type to the union, TypeScript forces you to handle the new branch in every call site.
| Pattern | Type-level error discrimination | Exhaustiveness check |
|---|---|---|
| A | any — manual instanceof chains | Manual |
| B | Unknown + guards | Manual |
| C | Error only | None |
| D | Full union type | Compiler-enforced |
When each pattern wins
No pattern is universally superior. Here’s the breakdown:
| Scenario | Recommended pattern | Why |
|---|---|---|
| Quick script, no error discrimination | A (catch any) | Minimal overhead. But add e: unknown as a habit — costs nothing. |
| Production API, re-throw unexpected | B (catch unknown) | Best balance of simplicity and safety. Re-throw unrecognized errors. |
| Data pipeline with sequential transforms | D (Result type) | The composability of .map(), .andThen(), and .orElse() eliminates nested error checks. |
| Library code called by others | D (Result type) | Your callers can handle errors without catching. Documented failure modes in the type signature. |
| Single async call in a handler | C (tuple) | Simple, readable. But only if you wrap it in a framework-level error boundary for unexpected throws. |
How to apply this
-
Audit your existing catch blocks — find every
catch (e)with no type annotation. Runtsc --noEmitthen search forcatch (in your codebase. Replace withcatch (e: unknown). -
Add the re-throw guard — every catch block that catches
unknownshould either handle the known error subclass or re-throw. The rule of thumb: if you can’t name the error type in aninstanceofcheck, you shouldn’t swallow it. -
Evaluate neverthrow for new services — the upfront cost is higher, but the enforcement scales. Install
eslint-plugin-neverthrowand enablemust-use-resultto prevent silent ignores. -
Standardize on one pattern per project — the worst outcome is a mix of all four in the same codebase. Pick the one that matches your error granularity needs and enforce it with lint rules.
-
Write a type guard utility for your domain errors:
// api-errors.ts — one file, every project needs this
export class NotFoundError extends Error { readonly code = "NOT_FOUND" as const; }
export class RateLimitError extends Error { readonly code = "RATE_LIMIT" as const; }
export type AppError = NotFoundError | RateLimitError;
export function isAppError(e: unknown): e is AppError {
return e instanceof NotFoundError || e instanceof RateLimitError;
}
Key takeaways
- Patterns A and C (catch any / tuple) silently swallow programming errors. They’ll hide a
ReferenceErrorbehind a “not found” response. Add a re-throw guard or use a framework error boundary. - Pattern B (catch unknown + guard) is the best default — simple, safe, and enforces error discrimination at the catch site without adding a library dependency.
- Pattern D (Result type) is the strongest for enforcement — the type system and lint rules together prevent unhandled results from reaching production. The cost is learning curve and verbosity.
- The worst pattern is inconsistency — mixing try/catch with Result types in the same project defeats the guarantees of both. Pick one and enforce it.
- Type narrowing in catch blocks is non-negotiable — TypeScript 4.0+ made
catch (e: unknown)standard. If you’re still using untyped catch, that’s the single highest-impact change you can make.
References
[1] TypeScript Handbook, “Try Catch” — https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces
[2] “Why Do Software Projects Fail? Root Cause Analysis of Production Incidents,” IEEE Software, 2023. Analysis of 200 JS/TS production outages tracking error handling as a contributing factor.
[3] supermacro/neverthrow, “Type-Safe Errors for JS & TypeScript.” — https://github.com/supermacro/neverthrow
[4] “Error Handling with Result Types,” TypeScript.TV, 2025. — https://typescript.tv/best-practices/error-handling-with-result-types/
[5] Kent C. Dodds, “Get a Catch Block Error Message with TypeScript,” 2022. — https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
📖 Related Reads
- ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
Cross-links automatically generated from CodeIntel Log.