Error Handling

Learn production-grade error handling in TypeScript using unknown, narrowing, typed error models, and safe boundaries. Proper error handling prevents silent failures and makes systems easier to debug and maintain.

On this page

Why error handling is a TypeScript topic

JavaScript errors are dynamic by nature: anything can be thrown, and many APIs return inconsistent error shapes. TypeScript helps you treat errors as part of your system design instead of an afterthought. In production, reliable error handling improves debugging, prevents silent failures, and makes behavior predictable for users and other services.

Anything can be thrown

In JavaScript, you can throw strings, numbers, objects, or Error instances. TypeScript cannot assume the thrown value is an Error. This is why the safest catch variable type is unknown.

try {
  throw "Bad request";
} catch (err: unknown) {
  // err is unknown
}

Narrowing unknown errors safely

Before accessing properties, narrow the error. The most common safe narrowing is checking err instanceof Error.

try {
  throw new Error("Something failed");
} catch (err: unknown) {
  if (err instanceof Error) {
    console.error(err.message);
  } else {
    console.error("Unknown error", err);
  }
}

Why you should avoid catch (err: any)

Using any removes safety and encourages unsafe property access. In production code, any in error handling often hides real issues until runtime.

Typed error models

For predictable systems, define error models that represent known failure cases. This is especially useful in service layers and API responses.

type AppError =
  | { type: "validation"; message: string; field?: string }
  | { type: "not_found"; message: string }
  | { type: "unauthorized"; message: string }
  | { type: "unexpected"; message: string };

function toAppError(err: unknown): AppError {
  if (err instanceof Error) {
    return { type: "unexpected", message: err.message };
  }
  return { type: "unexpected", message: "Unknown error" };
}

Result patterns instead of throwing

Many production codebases avoid throwing for expected failures and return a typed result instead. This makes control flow explicit and reduces try/catch noise.

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

function parseId(value: string): Result {
  const n = Number(value);
  if (Number.isNaN(n)) {
    return { ok: false, error: { type: "validation", message: "Invalid id", field: "id" } };
  }
  return { ok: true, value: n };
}

Handling errors at boundaries

Errors should be normalized at system boundaries: HTTP handlers, CLI entry points, job runners, and UI event handlers. Inside your core logic, keep errors typed and consistent. At boundaries, convert them into user-facing messages, logs, and HTTP status codes.

Logging with context

In production, the message alone is rarely enough. Include context: user id, request id, route, and key inputs. In TypeScript, define a typed logger context to keep logs consistent.

type LogContext = {
  requestId?: string;
  userId?: number;
  route?: string;
};

function logError(err: unknown, ctx: LogContext) {
  if (err instanceof Error) {
    console.error({ message: err.message, stack: err.stack, ...ctx });
  } else {
    console.error({ message: "Unknown error", err, ...ctx });
  }
}

Common mistakes

  • Assuming all thrown values are Error instances.
  • Using any in catch blocks and accessing properties unsafely.
  • Swallowing errors without logging or returning structured information.
  • Throwing for expected validation failures instead of returning a typed result.

Production guidance

  • Use catch (err: unknown) and narrow safely.
  • Normalize errors into a small set of typed variants.
  • Handle and format errors at boundaries (HTTP/UI/CLI).
  • Log with structured context for easier debugging.

What’s next

Next, we will cover external libraries: how to consume third-party packages safely, manage missing types, and avoid losing type safety at integration points.