TypeScript Typed Error Handling: Stop Using any in Catch Blocks
Stop writing catch (e: any) in your TypeScript applications. Learn how to implement TypeScript typed error handling using custom error classes, type guards, and functional error patterns.
If your codebase contains catch blocks that look like this, you have a silent production killer waiting to strike:
try {
await paymentGateway.charge(user.id, amount);
} catch (err: any) {
// Classic runtime crash: Cannot read properties of undefined (reading 'message')
logger.error("Payment failed: " + err.response.data.message);
showNotification(err.response.data.message);
}Lying to the compiler by casting caught exceptions to any is one of the most common anti-patterns in modern web development. It bypasses TypeScript's safety guarantees at the exact boundary where runtime behavior is most unpredictable: network boundaries, file system access, and database queries.
When you cast an error to any, you are asserting that you know exactly what the runtime environment will throw. You don't. A network request can fail with a TimeoutError, a 502 Bad Gateway HTML response, an AxiosError, or a system-level DNS failure. Treating exceptions as arbitrary objects with guaranteed schemas is how production outages go unlogged and unhandled.
The Core Problem: Why Casts and any Defeat TypeScript Typed Error Handling
By default, modern TypeScript configurations (specifically with useUnknownInCatchVariables enabled, which is standard in strict mode) enforce that caught errors are typed as unknown. This is a deliberate design decision. In JavaScript, you can throw literally anything:
throw "Something went wrong"; // Thrown as string
throw 500; // Thrown as number
throw null; // Thrown as nullBecause the runtime permits any data type to be thrown, the compiler cannot statically guarantee the shape of an error inside a catch block.
When developers encounter unknown in a catch block, they often find themselves blocked by compiler errors when trying to access properties like err.message. The path of least resistance is usually to cast the error using as any or as AxiosError.
This is highly fragile. If you cast to AxiosError, but the error actually thrown was a standard TypeError (e.g., trying to access a property on an undefined variable inside the try block), your error handler itself will crash when it tries to access err.response.data.
This is a critical flaw that we look for closely when evaluating code quality. For a deeper look at what separates junior implementations from production-ready systems, check out what we discuss in /blog/2026-05-22-what-senior-engineers-actually-look-for-in-code-reviews.
To achieve true TypeScript typed error handling, we must transition from assuming types to proving types at runtime, or bypassing the exception-throwing mechanism entirely for expected failures.
The Pattern: Classifying Failures & Narrowing Types
To build a resilient architecture, we must categorize errors into two distinct buckets:
- Unexpected Exceptions (System Failures): Out-of-memory errors, database connection losses, network dropouts, or programming bugs (e.g.,
NullPointerExceptions). These should be thrown, caught at a global boundary, logged, and translated into clean, non-leaky user responses. Refer to /blog/2026-05-25-the-production-grade-backend-api-security-checklist to ensure your global error handlers aren't leaking system internals to attackers. - Expected Failures (Domain Violations): Insufficient funds, invalid coupon codes, or email-already-exists errors. These are not exceptional events; they are valid business paths. They should be modeled as values rather than exceptions, using a functional
Resultpattern.
The diagram below illustrates how errors flow through a robust TypeScript architecture:
By decoupling these two pathways, we ensure that our application remains highly resilient under load. If you are dealing with high-throughput asynchronous processes, combining this error architecture with advanced concurrency patterns is vital; see our guide on /blog/2026-05-22-beyond-promiseall-advanced-typescript-async-await-patterns-for-high-throughput-s.
Real Code: Before vs. After
Let's look at a concrete implementation. We will refactor a fragile, unsafe payment checkout routine into a type-safe, resilient system.
Before: The Unsafe, Cast-Heavy Approach
interface CheckoutResponse {
success: boolean;
transactionId?: string;
}
// Unsafe service throwing arbitrary exceptions
class LegacyPaymentService {
async processPayment(amount: number): Promise<CheckoutResponse> {
if (amount <= 0) {
throw new Error("INVALID_AMOUNT");
}
if (amount > 10000) {
// Throwing an arbitrary object instead of an Error instance
throw { code: "LIMIT_EXCEEDED", limit: 10000 };
}
return { success: true, transactionId: "tx_98765" };
}
}
// Unsafe client consumption
async function handleCheckout(amount: number) {
const service = new LegacyPaymentService();
try {
const result = await service.processPayment(amount);
console.log(`Success: ${result.transactionId}`);
} catch (err: any) { // Anti-pattern: explicit 'any'
// CRASH RISK: err might not have a 'code' property
if (err.code === "LIMIT_EXCEEDED") {
console.error(`Failed: Amount exceeds limit of ${err.limit}`);
} else {
// CRASH RISK: err.message is undefined if a raw object was thrown
console.error(`System error: ${err.message.toUpperCase()}`);
}
}
}After: Implementing Robust TypeScript Typed Error Handling
We will fix this by introducing three architectural components:
- Custom Serialized Error Classes with explicit runtime type guards.
- A Type-Safe Result Type to handle expected business failures without throwing exceptions.
- Exhaustive Type Narrowing in catch blocks.
// ==========================================
// 1. Custom Error Classes & Type Guards
// ==========================================
export abstract class BaseApplicationError extends Error {
abstract readonly code: string;
abstract readonly statusCode: number;
constructor(message: string, public readonly contextualData?: Record<string, unknown>) {
super(message);
// Restore prototype chain for custom errors in ES5 environments
Object.setPrototypeOf(this, new.target.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
export class PaymentLimitExceededError extends BaseApplicationError {
readonly code = "PAYMENT_LIMIT_EXCEEDED";
readonly statusCode = 400;
constructor(public readonly limit: number, public readonly attempted: number) {
super(`Payment limit exceeded. Maximum allowed is ${limit}, but attempted ${attempted}.`, {
limit,
attempted,
});
}
}
export class NetworkTimeoutError extends BaseApplicationError {
readonly code = "NETWORK_TIMEOUT";
readonly statusCode = 503;
}
// Runtime Type Guard to verify if an unknown error matches our base error architecture
export function isApplicationError(error: unknown): error is BaseApplicationError {
return error instanceof BaseApplicationError;
}
// ==========================================
// 2. The Result Pattern for Expected Failures
// ==========================================
export type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };
export const ok = <T>(value: T): Result<T, never> => ({ success: true, value });
export const err = <E>(error: E): Result<never, E> => ({ success: false, error });
// Expected domain failure types
export type PaymentValidationFailure =
| { type: "INVALID_CURRENCY"; currency: string }
| { type: "INSUFFICIENT_FUNDS"; balance: number; required: number };
// ==========================================
// 3. Resilient Implementation
// ==========================================
class ProductionPaymentService {
// Expected failures are returned as values.
// Unexpected system failures are thrown.
async processPayment(
amount: number,
balance: number,
currency: string
): Promise<Result<{ transactionId: string }, PaymentValidationFailure>> {
// Domain Validation (Expected)
if (currency !== "USD") {
return err({ type: "INVALID_CURRENCY", currency });
}
if (amount > balance) {
return err({ type: "INSUFFICIENT_FUNDS", balance, required: amount });
}
// Exceptional Validation (Unexpected / System Limits)
if (amount > 10000) {
throw new PaymentLimitExceededError(10000, amount);
}
try {
// Simulate an unstable network connection
if (Math.random() > 0.9) {
throw new Error("Socket hang up"); // Unhandled standard library exception
}
return ok({ transactionId: "tx_secure_12345" });
} catch (networkErr) {
// Wrap generic third-party errors immediately at the system boundary
throw new NetworkTimeoutError("The upstream payment gateway timed out.", {
originalError: networkErr instanceof Error ? networkErr.message : String(networkErr)
});
}
}
}
// ==========================================
// 4. Safe Consumer Execution
// ==========================================
async function runCheckout() {
const service = new ProductionPaymentService();
try {
const result = await service.processPayment(12000, 15000, "USD");
// Handling Expected Failures via Result Monad
if (!result.success) {
switch (result.error.type) {
case "INVALID_CURRENCY":
console.warn(`User tried paying with unsupported currency: ${result.error.currency}`);
break;
case "INSUFFICIENT_FUNDS":
console.warn(`Declined. User balance: ${result.error.balance}. Required: ${result.error.required}`);
break;
default:
// Compile-time assertion of exhaustiveness
const _exhaustiveCheck: never = result.error;
throw new Error(`Unhandled failure type: ${JSON.stringify(_exhaustiveCheck)}`);
}
return;
}
console.log(`Successfully charged! Transaction ID: ${result.value.transactionId}`);
} catch (error: unknown) {
// Catch-all block handles ONLY exceptional, unexpected errors safely.
// There are NO unsafe casts ("as any") here.
if (isApplicationError(error)) {
// The compiler now knows this is a structured BaseApplicationError
console.error(`[${error.code}] Status: ${error.statusCode}. Details: ${error.message}`);
if (error instanceof PaymentLimitExceededError) {
// Specifically narrow to custom subclass properties
console.error(`Attempted Limit: ${error.attempted}. Limit Cap: ${error.limit}`);
}
} else if (error instanceof Error) {
// Handle standard JS errors (e.g., ReferenceError, TypeError)
console.error(`Internal System Error: ${error.message}`, { stack: error.stack });
} else {
// Fallback for raw thrown objects, strings, or nulls
console.error("An unhandled, non-standard error occurred:", String(error));
}
}
}Trade-offs of Structured Error Handling
No engineering pattern is a silver bullet. While this pattern guarantees type safety and significantly improves production observability, you must weigh the operational trade-offs before rolling it out across an entire monorepo.
| Metric | Simple try/catch with any | Custom Errors & Type Guards | The Result Pattern |
| :--- | :--- | :--- | :--- |
| Type Safety | None (Compiler is bypassed) | High (Guaranteed at runtime) | Maximum (Forced compile-time checks) |
| Boilerplate | Low | Medium (Requires custom classes) | High (Requires wrapping return values) |
| Performance | High | Normal (Negligible class creation) | Minimal (Lightweight object allocation) |
| Developer Ergonomics| Simple, familiar syntax | Standard JS semantics | Requires functional paradigm shift |
| Observability | Poor (No stack trace metadata) | High (Structured context data) | Low (Stack traces omitted on expected failures) |
Why I Recommend This Hybrid Approach
- For Expected Failures (Domain Logic): Use the
ResultPattern. It forces the developer using your API to explicitly handle failures. If your function can fail because a user's balance is too low, returning aResultforces them to handle that branch before accessing the success value. It entirely eliminates "forgotten" error paths. - For Unexpected Failures (System Boundaries): Use Custom Error Classes + Type Guards.
When dealing with database transactions, file system operations, or HTTP libraries (like Axios or Fetch), errors are going to be thrown. You cannot prevent this. Instead of converting your entire codebase into functional monadic pipes, catch these exceptions at the boundary, map them to standard
BaseApplicationErrorinstances, and use type guards to narrow them cleanly.
Conclusion: Eliminating "any" From Your Codebase
To transition your team to production-grade TypeScript typed error handling, enforce these three rules in your ESLint configurations and code review checklists:
- Configure ESLint to forbid explicit any casts: Use
@typescript-eslint/no-explicit-anyand@typescript-eslint/no-unsafe-member-access. - Enforce strict catch blocks: Ensure
useUnknownInCatchVariablesis set totruein yourtsconfig.json. This forces developers to treat caught exceptions asunknownrather than defaulting toany. - Define boundaries: Map third-party library errors (e.g., Prisma errors, Axios errors) into your local
AppErrordomains immediately at your infrastructure boundary layer. Do not let external library error shapes bleed into your business logic.
Related Posts
Vite 8.0.14 Released: Why Migrating to Vite 8 is Finally Production-Ready
Vite 8.0.14 addresses critical regressions in the Environment API and SSR module resolution. Read our complete guide to migrating to vite 8 for enterprise production stacks.
Google I/O 2026: WebMCP Is the New Standard for AI Agents
Explore the WebMCP browser standard announced at Google I/O 2026. Learn how native Model Context Protocol in the browser changes AI agent tool use with TypeScript.
Beyond Promise.all: Advanced TypeScript Async Await Patterns for High-Throughput Systems
Master advanced typescript async await patterns to eliminate race conditions, handle backpressure, and build highly resilient concurrent systems without breaking your type safety.