Error Handling
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.