Error Handling
TypeScript enhances JavaScript's error handling with type narrowing, custom error classes, and the Result pattern that makes error states explicit and impossible to ignore.
Try/Catch with Type Narrowing
In TypeScript, caught errors are typed as unknown, forcing you to check before using them.
function parseJSON(input: string): unknown {
try {
return JSON.parse(input);
} catch (error) {
if (error instanceof SyntaxError) {
console.log(`Syntax error: ${error.message}`);
} else if (error instanceof Error) {
console.log(`Error: ${error.message}`);
} else {
console.log(`Unknown error: ${String(error)}`);
}
return null;
}
}
console.log("Valid:", parseJSON('{"name": "Alice"}'));
console.log("Invalid:", parseJSON("{ bad json }"));
// try/catch/finally
function readConfig(filename: string): string {
try {
if (!filename) throw new Error("Filename cannot be empty");
return `config from ${filename}`;
} catch (error) {
if (error instanceof Error) console.log(`Failed: ${error.message}`);
return "default config";
} finally {
console.log(`Cleanup for: ${filename || "(empty)"}`);
}
}
console.log(`\nResult: ${readConfig("app.json")}`);
console.log(`Result: ${readConfig("")}`);
Custom Error Classes
Custom errors carry structured information about what went wrong.
class ValidationError extends Error {
constructor(public field: string, public value: unknown, message: string) {
super(message);
this.name = "ValidationError";
}
}
class NotFoundError extends Error {
constructor(public resource: string, public id: string | number) {
super(`${resource} with id '${id}' not found`);
this.name = "NotFoundError";
}
}
interface User { id: number; name: string; email: string }
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" }
];
function findUser(id: number): User {
if (id <= 0) throw new ValidationError("id", id, "Must be positive");
const user = users.find(u => u.id === id);
if (!user) throw new NotFoundError("User", id);
return user;
}
function handleRequest(userId: number): void {
try {
const user = findUser(userId);
console.log(`Found: ${user.name} (${user.email})`);
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation: '${error.field}' - ${error.message}`);
} else if (error instanceof NotFoundError) {
console.log(`Not found: ${error.resource} #${error.id}`);
}
}
}
handleRequest(1);
handleRequest(99);
handleRequest(-5);
The Result Pattern
Instead of throwing, make success and failure explicit in the return type.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
interface UserInput { name: string; email: string; age: number }
function validateUser(input: UserInput): Result<UserInput, string> {
if (input.name.trim().length < 2) return err("Name must be at least 2 characters");
if (!input.email.includes("@")) return err("Invalid email address");
if (input.age < 0 || input.age > 150) return err("Age must be between 0 and 150");
return ok(input);
}
function processUser(input: UserInput): void {
const result = validateUser(input);
if (result.ok) {
console.log(`Valid: ${result.value.name} (${result.value.email})`);
} else {
console.log(`Failed: ${result.error}`);
}
}
processUser({ name: "Alice", email: "alice@example.com", age: 30 });
processUser({ name: "", email: "bad", age: 200 });
processUser({ name: "Bob", email: "bob@co.org", age: 25 });
// Chaining results
function parseAge(input: string): Result<number, string> {
const num = Number(input);
if (isNaN(num)) return err(`'${input}' is not a number`);
if (!Number.isInteger(num)) return err("Must be a whole number");
if (num < 0) return err("Cannot be negative");
return ok(num);
}
console.log("\nParsing ages:");
["25", "abc", "30.5", "-1", "42"].forEach(input => {
const result = parseAge(input);
console.log(` "${input}" -> ${result.ok ? result.value : `Error: ${result.error}`}`);
});
Exhaustive Checking with Never
The never type ensures all cases are handled.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 5 },
{ kind: "rectangle", width: 10, height: 3 },
{ kind: "triangle", base: 8, height: 6 }
];
shapes.forEach(shape => {
console.log(`${shape.kind}: area = ${calculateArea(shape).toFixed(2)}`);
});
// Assert defined helper
function assertDefined<T>(value: T | undefined | null, name: string): T {
if (value == null) throw new Error(`'${name}' must be defined`);
return value;
}
const maybe: string | undefined = "hello";
console.log(`\nAsserted: ${assertDefined(maybe, "greeting")}`);
Try It Yourself
type MathError = "DIVISION_BY_ZERO" | "OVERFLOW" | "INVALID_INPUT";
type MathResult =
| { ok: true; value: number }
| { ok: false; error: MathError; message: string };
function safeDivide(a: number, b: number): MathResult {
if (isNaN(a) || isNaN(b)) return { ok: false, error: "INVALID_INPUT", message: "Inputs must be valid numbers" };
if (b === 0) return { ok: false, error: "DIVISION_BY_ZERO", message: "Cannot divide by zero" };
const result = a / b;
if (!isFinite(result)) return { ok: false, error: "OVERFLOW", message: "Result is not finite" };
return { ok: true, value: result };
}
function formatResult(a: number, b: number): string {
const result = safeDivide(a, b);
if (result.ok) return `${a} / ${b} = ${result.value.toFixed(4)}`;
switch (result.error) {
case "DIVISION_BY_ZERO": return `${a} / ${b} -> ${result.message}`;
case "OVERFLOW": return `${a} / ${b} -> ${result.message}`;
case "INVALID_INPUT": return `Invalid -> ${result.message}`;
default:
const _: never = result.error;
return `Unknown: ${_}`;
}
}
console.log("Safe division:");
console.log(formatResult(10, 3));
console.log(formatResult(100, 0));
console.log(formatResult(NaN, 5));
console.log(formatResult(42, 7));
Key Takeaways
- Caught errors are
unknown— always narrow withinstanceofbefore using - Custom error classes carry structured data for precise handling
- The Result pattern makes errors explicit in the type system
- The
nevertype ensures exhaustive case handling - Use
finallyfor cleanup that must always run - Use exceptions for bugs, Result for expected failures
Pro Tip: Use exceptions for programmer errors that should never happen in production. Use the Result pattern for expected failures like validation errors or missing resources. This distinction makes your code easier to reason about because the types tell you which functions can fail.
Next Steps
You've been writing your own types from scratch, but TypeScript ships with powerful built-in utilities that transform existing types. Next, you'll learn Partial, Pick, Omit, Record, and more — so you can derive new types without repeating yourself.
Next lesson
Utility Types
Master TypeScript utility types like Partial, Pick, Omit, and Record. Transform existing types without repetition.
22 min