AI Dev Tools
·5 min read·best practice

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:

typescript
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:

typescript
throw "Something went wrong"; // Thrown as string
throw 500;                    // Thrown as number
throw null;                   // Thrown as null

Because 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:

  1. 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.
  2. 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 Result pattern.

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

typescript
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:

  1. Custom Serialized Error Classes with explicit runtime type guards.
  2. A Type-Safe Result Type to handle expected business failures without throwing exceptions.
  3. Exhaustive Type Narrowing in catch blocks.
typescript
// ==========================================
// 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

  1. For Expected Failures (Domain Logic): Use the Result Pattern. 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 a Result forces them to handle that branch before accessing the success value. It entirely eliminates "forgotten" error paths.
  2. 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 BaseApplicationError instances, 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-any and @typescript-eslint/no-unsafe-member-access.
  • Enforce strict catch blocks: Ensure useUnknownInCatchVariables is set to true in your tsconfig.json. This forces developers to treat caught exceptions as unknown rather than defaulting to any.
  • Define boundaries: Map third-party library errors (e.g., Prisma errors, Axios errors) into your local AppError domains immediately at your infrastructure boundary layer. Do not let external library error shapes bleed into your business logic.
ShareTweet

Related Posts