Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 066 / 100

Error types in TypeScript: practical tips and trade-offs

catch gives you unknown — handle it properly.

Expona Admin8 min read

Error types in TypeScript: practical tips and trade-offs

JavaScript throws anything—strings, objects, null, undefined—but TypeScript's type system has to deal with that chaos. In a production system handling thousands of transactions per second, an unhandled error can cascade into silent failures or worse, corrupted state. We'll walk through error types in TypeScript: practical tips and trade-offs that let us build resilient error handling without drowning in try-catch boilerplate.

Caught errors are unknown — Anything can be thrown in JavaScript

When you write a try-catch block in JavaScript, the caught value has type unknown in TypeScript. This is correct and intentional. You can throw anything:

typescript
throw "string error";
throw 42;
throw { custom: "object" };
throw new Error("proper error");
throw null;

A library you're using might throw a custom class. A promise rejection might pass a plain object. Legacy code might throw strings. TypeScript forces you to acknowledge this reality by treating caught values as unknown:

typescript
try {
  await fetchUserData(userId);
} catch (error) {
  // error is unknown
  console.log(error); // ✓ allowed
  console.log(error.message); // ✗ Property 'message' does not exist on type 'unknown'
}

This strictness is a feature, not a bug. In a real e-commerce checkout flow, logging an error message without checking its type could crash your error handler, leaving the transaction in an undefined state. We've seen this happen—a third-party payment gateway threw an object without a message property, the error handler crashed trying to access it, and the payment confirmation logic never ran.

Narrow before use — Check instanceof Error before reading message

The practical solution is type narrowing. Check the type of the caught value before using its properties:

typescript
try {
  await processPayment(order);
} catch (error) {
  let message = "Unknown error";
  
  if (error instanceof Error) {
    message = error.message;
  } else if (typeof error === "string") {
    message = error;
  } else if (typeof error === "object" && error !== null && "detail" in error) {
    message = (error as any).detail;
  }
  
  logger.error({ message, error });
}

This is verbose, yes. But in systems handling payment reconciliation or order fulfillment, this verbosity prevents silent errors. We've found that adding even a single generic error handler at the top level of request handlers catches most real-world cases:

typescript
function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

function getErrorStack(error: unknown): string | undefined {
  if (error instanceof Error) {
    return error.stack;
  }
  return undefined;
}

Use these utilities consistently across your codebase. They become your contract: "everywhere we handle errors, we extract message and stack the same way." That contract makes debugging easier and makes you confident that error context isn't lost.

In NestJS or Express middleware, wrap them once at the top level:

typescript
app.use((error: unknown, req: Request, res: Response, next: NextFunction) => {
  const message = getErrorMessage(error);
  const stack = getErrorStack(error);
  
  logger.error({ message, stack, path: req.path });
  res.status(500).json({ error: message });
});

Typed domain errors — Custom classes with codes for expected failures

Not all errors are surprises. Payment declines, invalid input, rate limits—these are expected outcomes. Throwing generic Error for these cases loses information and mixes concerns.

Instead, create domain-specific error classes:

typescript
class DomainError extends Error {
  constructor(
    public code: string,
    message: string,
    public statusCode: number = 500,
  ) {
    super(message);
    this.name = this.constructor.name;
  }
}

class PaymentDeclinedError extends DomainError {
  constructor(reason: string) {
    super("PAYMENT_DECLINED", `Payment declined: ${reason}`, 402);
  }
}

class ValidationError extends DomainError {
  constructor(
    public field: string,
    message: string,
  ) {
    super("VALIDATION_ERROR", message, 400);
  }
}

class InsufficientInventoryError extends DomainError {
  constructor(
    public sku: string,
    public requested: number,
    public available: number,
  ) {
    super(
      "INSUFFICIENT_INVENTORY",
      `SKU ${sku}: requested ${requested}, available ${available}`,
      409,
    );
  }
}

Now your business logic is explicit:

typescript
async function checkoutOrder(
  cart: CartItem[],
  paymentToken: string,
): Promise {
  // Validate input
  if (!cart.length) {
    throw new ValidationError("cart", "Cart is empty");
  }
  
  // Check inventory
  for (const item of cart) {
    const available = await getInventory(item.sku);
    if (available < item.quantity) {
      throw new InsufficientInventoryError(
        item.sku,
        item.quantity,
        available,
      );
    }
  }
  
  // Process payment
  const result = await stripe.charge({
    amount: calculateTotal(cart),
    token: paymentToken,
  });
  
  if (!result.success) {
    throw new PaymentDeclinedError(result.decline_reason);
  }
  
  return createOrder(cart);
}

In your HTTP handler, these errors map cleanly to responses:

typescript
app.post("/checkout", async (req, res) => {
  try {
    const order = await checkoutOrder(req.body.cart, req.body.token);
    res.status(201).json(order);
  } catch (error) {
    if (error instanceof DomainError) {
      return res.status(error.statusCode).json({
        code: error.code,
        message: error.message,
      });
    }
    
    // Unknown error
    logger.error({ error, path: req.path });
    res.status(500).json({ error: "Internal server error" });
  }
});

Your API consumers get consistent error codes. Your logs are structured. Your team doesn't spend hours debugging "what went wrong?" because the error object itself tells you—it's a PAYMENT_DECLINED, not a mystery 500.

Results for expected failures — Return types make errors explicit

Domain errors are good, but they still use exceptions. In systems where certain failures are routine, exceptions add overhead and obscure the happy path. Consider a Result type instead:

typescript
type Result = 
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok(value: T): Result {
  return { ok: true, value };
}

function err(error: E): Result {
  return { ok: false, error };
}

Rewrite your checkout to return results:

typescript
type CheckoutError = 
  | { type: "validation"; field: string; message: string }
  | { type: "inventory"; sku: string; requested: number; available: number }
  | { type: "payment_declined"; reason: string };

async function checkoutOrder(
  cart: CartItem[],
  paymentToken: string,
): Promise> {
  if (!cart.length) {
    return err({ 
      type: "validation", 
      field: "cart", 
      message: "Cart is empty" 
    });
  }
  
  for (const item of cart) {
    const available = await getInventory(item.sku);
    if (available < item.quantity) {
      return err({
        type: "inventory",
        sku: item.sku,
        requested: item.quantity,
        available,
      });
    }
  }
  
  const paymentResult = await stripe.charge({
    amount: calculateTotal(cart),
    token: paymentToken,
  });
  
  if (!paymentResult.success) {
    return err({
      type: "payment_declined",
      reason: paymentResult.decline_reason,
    });
  }
  
  const order = await createOrder(cart);
  return ok(order);
}

Your handler is straightforward—no try-catch needed:

typescript
app.post("/checkout", async (req, res) => {
  const result = await checkoutOrder(req.body.cart, req.body.token);
  
  if (!result.ok) {
    const error = result.error;
    
    if (error.type === "validation") {
      return res.status(400).json({ code: "VALIDATION_ERROR", message: error.message });
    }
    if (error.type === "inventory") {
      return res.status(409).json({ 
        code: "INSUFFICIENT_INVENTORY",
        sku: error.sku,
        requested: error.requested,
        available: error.available,
      });
    }
    if (error.type === "payment_declined") {
      return res.status(402).json({ code: "PAYMENT_DECLINED", reason: error.reason });
    }
  }
  
  res.status(201).json(result.value);
});

The trade-off: Results are explicit about failure paths, but they're more boilerplate. Use them for domain logic where failures are expected and recoverable. Reserve exceptions for truly exceptional cases—bugs, external service outages, resource exhaustion.

Common mistakes

  • Catching without narrowing. Don't write catch (error: Error) and assume it's an Error. The type system allows it for backward compatibility, but it's unsafe. Always narrow with instanceof.
  • Mixing error strategies. Don't use domain errors in some functions and generic errors in others. Pick one pattern per system and stick to it. Your team needs to know where to look.
  • Throwing in a try-finally without handling. If your cleanup code (finally block) throws, the original error is lost. Either handle it or chain it: throw new Error("cleanup failed", { cause: originalError }).
  • Over-specifying Result types. If you return Result<string, ErrorA | ErrorB | ErrorC>, you've recreated exception handling with more typing. Keep Result error types small—usually 2-3 variants per function.

Key takeaways

  • Caught values are unknown in TypeScript. Check type with instanceof Error before accessing properties.
  • Create domain-specific error classes with codes and status codes for expected failures. Map them consistently to HTTP responses.
  • Use Result types for routine failures where control flow matters (checkout, validation, retries). Use exceptions for truly exceptional cases.
  • Always have a top-level error handler that safely extracts message and stack from unknown values. Share this logic across your app.
  • Log error codes, not just messages. Your future self debugging production will thank you.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles