TL;DR
Learn robust TypeScript error handling with try/catch, custom error classes, the Result pattern, and type narrowing for caught errors.
Key concepts
- TypeScript error handling
- try catch TypeScript
- Result pattern TypeScript
- custom errors TypeScript
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.
What You'll Learn
By the end of this lesson you will write a validator that takes untrusted input and hands back a value describing either a success or a failure — never a thrown exception the caller can forget to catch — and you will be able to narrow a caught unknown down to something you can safely read a message off.
This is half of the validation-layer milestone, and it is the half that decides the shape of the other. The tracker turns raw input into domain values, and every rejection has to reach the caller as data rather than as a crash; the Result type you build here is what carries it. Zod And Validation supplies the parsing rules later — this lesson supplies the channel they report through.
You arrive able to model a value that is one of several shapes, each tagged by a literal field, and to narrow it with a check on that tag (Interfaces And Types and Type Narrowing). A Result is that pattern again with two members, ok: true and ok: false. What is new is using it as a return type, so failure travels through the signature instead of around it.
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";
}
}
// TODO: ValidationError above is the model. NotFoundError is missing the same
// two moves: a super(...) call that builds a message from resource and id, and
// a this.name assignment. Supply both, then run it again and watch the last
// line — until you do, it reports name="Error" and an empty message.
class NotFoundError extends Error {
constructor(public resource: string, public id: string | number) {
super();
}
}
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);
// What the error itself reports, independent of the catch branch above:
const probe = new NotFoundError("User", 99);
console.log(`name="${probe.name}" message="${probe.message}"`);
The catch branch prints resource and id, which the parameter properties supply on their own — so a half-built error still looks fine there. The probe line is what exposes it: Error's own name and message come from the super(...) call and the this.name assignment, and with those missing you get name="Error" message="". Fill both in as ValidationError does and the line reads name="NotFoundError" message="User with id '99' not found".
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.
This is a trade, not an upgrade, and it is worth knowing which side you are choosing. Throwing keeps the happy path clean — the failure jumps straight past every intermediate caller to whoever catches it — but nothing in a function's signature records that it can fail, so the caller has no way to be reminded. Returning a Result puts the failure in the return type, where the caller cannot read the value without first dealing with the ok: false case; the cost is that every intermediate caller now has to pass it along explicitly. Reach for Result at the boundaries where bad input is expected and the caller has a real decision to make, and leave throwing for the genuinely exceptional. Watch for that division in the example below.
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, then logs the result. The lines are shuffled; put them back so the program runs and logs value: 84. Then answer why this order: which line could not have been moved, and what does the runner do if you move it?
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
Transfer
The Result type in this lesson is a union of an ok: true branch carrying a value and an ok: false branch carrying an error, and the caller has to check the discriminant before reading either. Rust's standard library ships the same idea as a built-in enum with Ok and Err variants, and adds a question-mark operator that returns early from the current function when it meets an Err. Which statement names what genuinely carries across, rather than a surface resemblance?
// TypeScript, this lesson
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// Rust, the same shape in the standard library
// enum Result<T, E> { Ok(T), Err(E) }
//
// fn total(raws: &[&str]) -> Result<u32, String> {
// let mut sum = 0;
// for r in raws {
// sum += parse_minutes(r)?; // ? returns the Err early
// }
// Ok(sum)
// }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!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.