Error Handling in TypeScript
Build robust error handling with Result types, exhaustive checks, type guards, and assertion functions in TypeScript.
The Problem with try/catch
JavaScript’s exception system has two problems that TypeScript exposes clearly. First, anything can be thrown — not just Error objects, but strings, numbers, or plain objects. Second, a function’s type signature gives no hint that it might throw, so callers have no way to know which functions need try/catch without reading the implementation. TypeScript 4.0 addressed the first problem by typing caught errors as unknown instead of any, forcing you to check the type before using it.
// TypeScript 4.0+ with strict mode: error is unknown, not any
try {
JSON.parse("invalid");
} catch (err) {
console.log(err.message); // Error: 'err' is of type 'unknown'
}
You must narrow the error type before using it:
try {
JSON.parse("invalid");
} catch (err) {
if (err instanceof Error) {
console.log(err.message); // safe — narrowed to Error
} else {
console.log(String(err)); // fallback for thrown non-Error values
}
}
A reusable helper that normalizes any thrown value into an Error:
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(String(value));
}
try {
riskyOperation();
} catch (err) {
const error = toError(err);
console.log(error.message); // always safe
}
Type Guards
Type guards let TypeScript narrow a broad type — like a union or unknown — down to a specific type within a conditional branch. This is how you write code that handles multiple possible input shapes correctly without resorting to as casts. TypeScript recognizes several built-in narrowing patterns and also lets you define your own.
typeof guard — for primitive types:
function process(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // TypeScript knows: string here
}
return value.toFixed(2); // TypeScript knows: number here
}
instanceof guard — for class hierarchies:
function handleError(err: unknown): string {
if (err instanceof TypeError) {
return `Type error: ${err.message}`;
}
if (err instanceof RangeError) {
return `Range error: ${err.message}`;
}
if (err instanceof Error) {
return err.message;
}
return String(err);
}
in operator guard — for distinguishing object shapes by property presence:
interface Cat { meow(): void }
interface Dog { bark(): void }
function makeSound(animal: Cat | Dog): void {
if ("meow" in animal) {
animal.meow(); // TypeScript knows: Cat
} else {
animal.bark(); // TypeScript knows: Dog
}
}
User-defined type guard (value is Type) — when built-in narrowing isn’t enough, you can write a function whose return type tells TypeScript what the type is after a truthy check. This is essential for validating unknown data from APIs or user input:
interface User {
id: number;
name: string;
email: string;
}
// The "value is User" return type is the type predicate —
// TypeScript uses it to narrow value in any branch where this returns true
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
typeof (value as any).id === "number" &&
typeof (value as any).name === "string" &&
typeof (value as any).email === "string"
);
}
async function fetchUser(id: number): Promise<User> {
const data = await fetch(`/api/users/${id}`).then((r) => r.json());
if (!isUser(data)) {
throw new Error("Invalid user data from API");
}
return data; // TypeScript is now confident this is User
}
Assertion Functions
Assertion functions are a variation on type guards: instead of returning a boolean, they throw when the condition fails and narrow the type for all code that follows them. They’re particularly useful for removing null checks in situations where you know a value should be non-null and want to make that assumption explicit rather than sprinkling ! everywhere.
// asserts condition — narrows the condition type to true after this call
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}
// asserts value is NonNullable<T> — removes null/undefined from the type
function assertNonNull<T>(value: T | null | undefined, name: string): asserts value is NonNullable<T> {
if (value == null) throw new Error(`${name} must not be null`);
}
// Usage
const element = document.getElementById("app");
assertNonNull(element, "app element");
// After this line, TypeScript knows element is HTMLElement — not HTMLElement | null
element.innerHTML = "<h1>Hello</h1>";
// With assert:
const user = getUser();
assert(user.role === "admin", "Admin required");
// user.role is narrowed to "admin" after this line
The Result Type Pattern
The fundamental problem with exceptions is that they’re invisible in function signatures. A function that throws tells you nothing about what can go wrong or when. The Result pattern makes failure an explicit, typed part of the return value — callers must handle both cases, and TypeScript enforces this at every call site. This is particularly valuable for operations that fail in predictable ways, like parsing, validation, and network requests.
type Ok<T> = { ok: true; value: T };
type Err<E = Error> = { ok: false; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;
// Helper constructors for clean call sites
function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
function err<E = Error>(error: E): Err<E> {
return { ok: false, error };
}
// Instead of throwing, return a typed result — callers know this can fail
function parseJson(raw: string): Result<unknown, string> {
try {
return ok(JSON.parse(raw));
} catch {
return err("Invalid JSON");
}
}
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err("Division by zero");
return ok(a / b);
}
// TypeScript requires handling both branches before accessing the value
const result = divide(10, 0);
if (result.ok) {
console.log(result.value); // number — only accessible in the ok branch
} else {
console.error(result.error); // string — only accessible in the err branch
}
Chaining Results without deeply nested if-checks:
// map: transform the success value, pass errors through unchanged
function mapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => U
): Result<U, E> {
if (result.ok) return ok(fn(result.value));
return result;
}
// flatMap: chain operations that also return Results
function flatMapResult<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
if (result.ok) return fn(result.value);
return result;
}
const result = flatMapResult(
parseJson('{"value": 42}'),
(data) => {
if (typeof data === "object" && data !== null && "value" in data) {
return ok((data as any).value as number);
}
return err("Missing value field");
}
);
Exhaustive Checks
When you add a new member to a union type, you want the compiler to find every switch statement and if chain that handles that union and flag them as incomplete. TypeScript’s never type makes this possible — after all known cases are handled, the remaining type should be never. If it isn’t, a case was missed, and TypeScript reports an error.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// If a new shape is added to the union and not handled above,
// TypeScript errors here because shape would not be 'never'
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
}
}
Custom Error Classes
Using a single Error class for all failures means every catch block must parse error messages or use fragile string matching to distinguish what went wrong. A typed error hierarchy solves this — each error class carries structured data relevant to its kind of failure, and instanceof checks in catch blocks give you clean, type-safe branching.
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500
) {
super(message);
this.name = this.constructor.name;
// Required when targeting ES5 — fixes instanceof checks on subclasses
Object.setPrototypeOf(this, new.target.prototype);
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: number | string) {
super(`${resource} with id '${id}' not found`, "NOT_FOUND", 404);
}
}
class ValidationError extends AppError {
constructor(
message: string,
public readonly fields: Record<string, string[]>
) {
super(message, "VALIDATION_ERROR", 422);
}
}
class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super(message, "UNAUTHORIZED", 401);
}
}
// Usage in an Express error handler — each branch has the right typed properties
function errorHandler(err: unknown, res: any) {
if (err instanceof NotFoundError) {
return res.status(404).json({ error: err.message, code: err.code });
}
if (err instanceof ValidationError) {
// err.fields is only available here — ValidationError is the only class with it
return res.status(422).json({ error: err.message, fields: err.fields });
}
if (err instanceof UnauthorizedError) {
return res.status(401).json({ error: err.message });
}
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
// Unknown error — log it, don't expose internals
console.error(err);
return res.status(500).json({ error: "Internal server error" });
}
Practical Example: Validated Parsing
Combining Result types with field-level error reporting gives you a validation function that callers can use directly in request handlers — it returns either a typed value or a list of specific field errors, with no exceptions involved and no ambiguity about what went wrong.
type ParseError = { field: string; message: string };
function parseUserInput(raw: unknown): Result<User, ParseError[]> {
if (typeof raw !== "object" || raw === null) {
return err([{ field: "root", message: "Expected an object" }]);
}
const errors: ParseError[] = [];
const obj = raw as Record<string, unknown>;
if (typeof obj.name !== "string" || obj.name.length < 2) {
errors.push({ field: "name", message: "Name must be at least 2 characters" });
}
if (typeof obj.email !== "string" || !obj.email.includes("@")) {
errors.push({ field: "email", message: "Valid email required" });
}
// Collect all errors before returning — don't fail on the first one
if (errors.length > 0) return err(errors);
return ok({ id: 0, name: obj.name as string, email: obj.email as string });
}
const input = JSON.parse(requestBody);
const result = parseUserInput(input);
if (!result.ok) {
// TypeScript knows result.error is ParseError[] here
result.error.forEach(({ field, message }) => {
console.error(` ${field}: ${message}`);
});
} else {
// TypeScript knows result.value is User here
await saveUser(result.value);
}