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("")}`);
This is one of the clearest places to see the two-lane nature of TypeScript: a check the compiler enforces that the runner cannot. The Predict below is a compiler-behavior question — the exact error tsc --strict reports appears first:
error TS18046: 'error' is of type 'unknown'.
Predict
In strict mode the caught error is typed unknown. The code reads error.message with NO narrowing. Think about what tsc --strict reports — and, separately, what the tsx runner (which strips types) does when it runs the same code. Which pair below is right?
function risky(): void {
try {
throw new Error("boom");
} catch (error) {
// no instanceof / narrowing here
console.log(error.message);
}
}
risky();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);
Debug
This should format any caught error: an HttpError shows [status] message, anything else shows [unknown] message. It should never print [undefined]. But the assert fails on the plain-Error case. The types look fine at a glance — but the runner strips them and runs it anyway. Predict what the plain Error formats to before running, then fix it so both cases pass.
import assert from "node:assert";
class HttpError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
this.name = "HttpError";
}
}
// Intent: format a caught error. HttpErrors show "[status] message";
// any other error shows "[unknown] message". Should never print "[undefined]".
function format(error: unknown): string {
// The cast ASSUMES every error is an HttpError instead of checking.
const http = error as HttpError;
return `[${http.statusCode}] ${http.message}`;
}
const a = format(new HttpError(404, "Not Found"));
const b = format(new Error("generic failure"));
assert.strictEqual(a, "[404] Not Found", "an HttpError should show its status code, got " + a);
assert.strictEqual(b, "[unknown] generic failure", "a plain Error should show [unknown], got " + b);
console.log(a);
console.log(b);Expected output: [404] Not Found
[unknown] generic failure
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}`}`);
});
Recall
Without scrolling up: the Result<T, E> type is { ok: true; value: T } | { ok: false; error: E }, and the code checks if (result.ok) before reading result.value. You already met this exact machinery in *Type Narrowing*. What is Result an instance of, and why does if (result.ok) let you read .value with no cast?
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")}`);
The safeDivide example below applies the Result pattern to arithmetic — every failure mode is a tagged member, and the caller narrows on result.error with a never exhaustiveness check. It runs as-is:
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));
Arrange the code
Reassemble a program that builds a success Result with an ok constructor, reads its value, doubles it, formats a label by narrowing on the ok discriminant, and logs value: 84. The lines are shuffled. Each const consumes the binding above it — and the constructor must exist before it is called — so only one order runs top-to-bottom.
const ok = (value: number) => ({ ok: true as const, value });const label = parsed.ok ? `value: ${doubled}` : "failed";console.log(label);const doubled = parsed.value * 2;const parsed = ok(42);
Try It Yourself
Reading about the Result pattern is not the same as building a validation layer with it. This is a build task: a small program that reports its own pass/fail. You finish a validator that returns a Result for an ActivityInput and a collector that gathers the failures — the exact validation layer the tracker capstone surfaces errors through. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
The pieces reuse exactly what this lesson taught: returning err(...) on the FIRST failing rule and ok(input) when everything passes, and reading the Result discriminant (if (!r.ok)) to collect the error strings. The starter has the Result type, the ok/err constructors, and the checks — you write only the logic inside each function.
Build
Finish the build. Two functions are stubbed, and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The Result type and constructors are given. Do NOT change these.
type Result<T, E = string> =
| { 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 ActivityInput {
title: string;
minutesSpent: number;
}
// TODO 1: validate an ActivityInput, returning a Result. Check IN THIS ORDER and
// return the FIRST failure:
// - title trimmed length < 1 -> err("title is required")
// - minutesSpent is not > 0 -> err("minutesSpent must be positive")
// Otherwise return ok(input).
// validate({ title: "Types", minutesSpent: 30 }).ok -> true
function validate(input: ActivityInput): Result<ActivityInput, string> {
// your code here
return ok(input); // replace this
}
// TODO 2: run validate over a list, returning ONLY the error strings of the
// invalid inputs, in order. Read the Result's discriminant to decide.
// errorsFor([...]) -> ["title is required", "minutesSpent must be positive"]
function errorsFor(inputs: ActivityInput[]): string[] {
// your code here
return []; // replace this
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const good = validate({ title: "Types", minutesSpent: 30 });
assert.strictEqual(good.ok, true, "TODO 1: a valid input should return an ok Result");
const emptyTitle = validate({ title: " ", minutesSpent: 30 });
assert.deepStrictEqual(emptyTitle, { ok: false, error: "title is required" }, "TODO 1: an empty title should fail with the title error");
const badMinutes = validate({ title: "Types", minutesSpent: 0 });
assert.deepStrictEqual(badMinutes, { ok: false, error: "minutesSpent must be positive" }, "TODO 1: zero minutes should fail with the minutes error");
const bothBad = validate({ title: "", minutesSpent: -5 });
assert.deepStrictEqual(bothBad, { ok: false, error: "title is required" }, "TODO 1: when both are invalid, the title check runs first");
const errs = errorsFor([
{ title: "Types", minutesSpent: 30 },
{ title: "", minutesSpent: 20 },
{ title: "Async", minutesSpent: 0 },
]);
assert.deepStrictEqual(errs, ["title is required", "minutesSpent must be positive"], "TODO 2: errorsFor should collect the error strings of the invalid inputs, in order");
console.log("All checks passed.");
console.log("Valid input ok:", good.ok);
console.log("Errors:", errorsFor([
{ title: "", minutesSpent: 20 },
{ title: "Async", minutesSpent: 0 },
]).join("; "));Expected output: All checks passed.
Valid input ok: true
Errors: title is required; minutesSpent must be positive
Once it passes, try two variations and predict each before running:
- Reverse the rule order. In
validate, swap the two checks sominutesSpentis tested BEFOREtitle. Predict which check fails first before running. The individually-invalid inputs still report the same errors, butbothBad(title: "",minutesSpent: -5) now fails the minutes rule first, returningminutesSpent must be positiveinstead oftitle is required— so TODO 1's fourth check fires withAssertionError: TODO 1: when both are invalid, the title check runs first. An instructive assert failure showing that with short-circuit validation, the ORDER of the rules decides which error a multiply-invalid input reports. - Count the valid inputs. After the checks pass, add
const mixed = [{ title: "Types", minutesSpent: 30 }, { title: "", minutesSpent: 20 }]; console.log("Valid count:", mixed.filter((i) => validate(i).ok).length, "of", mixed.length);below the logs. Predict the new line before running.validate(i).okis the discriminant again — one input passes, one fails — so you getValid count: 1 of 2. This changes the echoed output, not any check, and shows the same Result driving a filter instead of an error collector.
Capstone milestone
Milestone — the validation layer (the Result half). The tracker validates untrusted input before it enters the domain model, and it surfaces failures as values — a Result carrying either the accepted input or the first error — rather than throwing. The validate/errorsFor pair you just built is that layer's Result-returning core. Confirm you can make failure explicit in the return type and read it back through the discriminant.
Hint: This is the validation-layer milestone, shared with *Zod And Validation* (Zod schemas validating untrusted input). Here the load-bearing part is the Result pattern making failure a value; in the capstone this exact shape carries validation errors out of the boundary before anything reaches the domain model.
- Returned a Result (ok/err) instead of throwing, so failure is explicit in the type
- Short-circuited on the FIRST failing rule, in a deliberate rule order
- Read the Result discriminant (!r.ok) to handle success and failure separately — no casts
- Collected error strings from the failures without letting an exception escape
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
Errors get far more interesting once the work is asynchronous — a rejected Promise, a failed await, several operations racing at once. Next, you'll learn async TypeScript: typed Promises, async/await, and how caught errors stay unknown across the await boundary so the narrowing you just learned still applies.
Ready to continue? Head to Async TypeScript!
Next lesson
Async TypeScript
Master async/await in TypeScript with typed Promises, async functions, and error handling patterns for non-blocking, readable code.
27 min