Emmanuel Akinfulubi

TypeScript: Error handling

try/catch, custom error classes, and passing errors up through the layers of a program.

12 exercises

1 Throw when a condition is not met, then catch it

easy

Write a function that throws when some condition fails. Wrap a call to it in try/catch and report the message.

Stuck? Three hints, smallest first
  1. 1. Throw an Error object, never a bare string. Only objects carry a stack trace.
  2. 2. In TypeScript the caught value is typed unknown, so you must narrow it before reading .message.
  3. 3. err instanceof Error is the narrowing you want.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function withdraw(balance: number, amount: number): number {
  if (amount > balance) {
    throw new Error(`cannot withdraw ${amount}; balance is ${balance}`);
  }
  return balance - amount;
}

// The catch parameter is 'unknown', so it has to be narrowed before use.
function attempt(fn: () => number): string {
  try {
    return `ok: ${fn()}`;
  } catch (err) {
    if (err instanceof Error) return `refused: ${err.message}`;
    return 'refused: something that was not an Error';
  }
}

console.log(attempt(() => withdraw(1000, 400)));
console.log(attempt(() => withdraw(1000, 5000)));

// Why 'throw new Error' and not 'throw "text"': only the object has a stack.
let hasStack = false;
try {
  withdraw(10, 99);
} catch (err) {
  hasStack = err instanceof Error && typeof err.stack === 'string';
}

test('a permitted withdrawal', withdraw(1000, 400), 600);
test('the good path reports ok', attempt(() => withdraw(1000, 400)), 'ok: 600');
test('the bad path is caught', attempt(() => withdraw(1000, 5000)), 'refused: cannot withdraw 5000; balance is 1000');
test('the thrown value carried a stack', hasStack, true);

Output


      

The detail people trip on is that catch (err) gives you unknown, not Error. That is correct, because JavaScript lets you throw anything at all. The instanceof Error check is what turns it into something you can read .message from.

Answers TS Errors 1 in the original set.

2 Catch a call to something that does not exist

easy

Call a function that is not there and catch the resulting error, reporting your own message instead of the raw one.

Stuck? Three hints, smallest first
  1. 1. Calling a missing property gives a TypeError, not a plain Error.
  2. 2. TypeScript will refuse the obvious version at compile time, which is the point: you have to go through an untyped value to reach this bug.
  3. 3. Check the specific class with err instanceof TypeError so you handle only what you expect.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// TypeScript stops the obvious version:
//   const tools = {};
//   tools.missing();   // Property 'missing' does not exist on type '{}'.
//
// To reach this bug you have to lose the type first, which is exactly how it
// happens in real code: data arrives from JSON, or from an 'any' at a boundary.
const untyped: Record<string, unknown> = { present: () => 'I am here' };

function callByName(bag: Record<string, unknown>, name: string): string {
  const maybeFn = bag[name];
  if (typeof maybeFn !== 'function') {
    throw new TypeError(`${name} is not a function on this object`);
  }
  return String((maybeFn as () => unknown)());
}

function safely(name: string): string {
  try {
    return callByName(untyped, name);
  } catch (err) {
    if (err instanceof TypeError) return `no such tool: ${err.message}`;
    throw err;                    // anything unexpected keeps travelling
  }
}

console.log(safely('present'));
console.log(safely('missing'));

// What the raw failure looks like when nothing guards it:
let rawName = '';
try {
  (untyped['missing'] as () => void)();
} catch (err) {
  rawName = err instanceof Error ? err.constructor.name : 'unknown';
}

test('the real function is called', safely('present'), 'I am here');
test('the missing one is reported kindly', safely('missing'), 'no such tool: missing is not a function on this object');
test('the raw failure is a TypeError', rawName, 'TypeError');

Output


      

Worth noticing that TypeScript refuses the naive version of this exercise outright, so the interesting question becomes how an untyped value got in. The answer is always a boundary: JSON, a form, a library without types. Guard at the boundary, as callByName does, and the rest of your code stays typed.

Answers TS Errors 2 in the original set.

3 Parse a whole number safely

easy

Write a function that turns text into a whole number and deals with input that is not a number. Handle the failure rather than passing NaN on.

Stuck? Three hints, smallest first
  1. 1. parseInt does not throw. It returns NaN, which is of type number, so nothing warns you.
  2. 2. parseInt('12abc', 10) is 12. Number('12abc') is NaN. Decide which you want.
  3. 3. Either throw, or return a result object saying whether it worked. Show both.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// Version one: throw on bad input.
function parseIntStrict(text: string): number {
  // Number('') is 0, and so is Number('   '). Without this guard, an empty
  // form field would quietly become a valid zero.
  if (text.trim() === '') {
    throw new SyntaxError('no number was given');
  }
  const n = Number(text);            // stricter than parseInt
  if (!Number.isInteger(n)) {
    throw new SyntaxError(`"${text}" is not a whole number`);
  }
  return n;
}

// Version two: return the outcome instead of throwing, for bulk input.
type Parsed = { ok: true; value: number } | { ok: false; reason: string };

function parseIntSafe(text: string): Parsed {
  try {
    return { ok: true, value: parseIntStrict(text) };
  } catch (err) {
    return { ok: false, reason: err instanceof Error ? err.message : 'unknown' };
  }
}

for (const input of ['42', '  7 ', '12abc', 'nine', '2.5', '']) {
  const r = parseIntSafe(input);
  console.log(JSON.stringify(input), '->', r.ok ? r.value : r.reason);
}

// Two traps the strict version avoids:
const looseParse = parseInt('12abc', 10);
const emptyIsZero = Number('');
console.log('parseInt("12abc", 10) is', looseParse);
console.log('Number("") is', emptyIsZero, '-- not NaN');

test('a clean number', parseIntStrict('42'), 42);
test('surrounding spaces are fine', parseIntStrict('  7 '), 7);
test('trailing letters are rejected', parseIntSafe('12abc').ok, false);
test('words are rejected', parseIntSafe('nine').ok, false);
test('fractions are rejected', parseIntSafe('2.5').ok, false);
test('empty text is rejected', parseIntSafe('').ok, false);
test('whitespace alone is rejected too', parseIntSafe('   ').ok, false);
test('but parseInt would have accepted 12abc', looseParse, 12);
test('and Number("") is a plausible-looking zero', emptyIsZero, 0);

Output


      

The last two assertions are the reason this exercise exists. parseInt('12abc', 10) quietly gives you 12, so a typo becomes a plausible number. Worse, Number('') is 0, not NaN, and so is Number(' ') — meaning an empty form field sails through Number.isInteger as a valid zero. That one caught me while writing this exercise, and only the assertion found it. Hence the trim() === '' guard before any conversion.

Answers TS Errors 3 in the original set.

4 Handle a missing file

fair

Try to open a file that is not there and handle the not-found failure with a clear message. Adapted: there is no file system in a browser, so this uses an in-memory store that fails the same way, with the real Node code shown alongside.

Stuck? Three hints, smallest first
  1. 1. Node throws an error carrying a code property; for a missing file it is 'ENOENT'.
  2. 2. Match on that code, not on the message text, because messages change between versions.
  3. 3. Model it with a custom error that also carries a code, so the handling logic is identical.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The real thing, for reference. It cannot run here:
//
//   import { readFileSync } from 'node:fs';
//   try {
//     const text = readFileSync('missing.txt', 'utf8');
//   } catch (err) {
//     if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT') { ... }
//   }
//
// The stand-in below fails in the same shape: an Error with a .code of ENOENT.

class FileError extends Error {
  constructor(message: string, public readonly code: string, public readonly path: string) {
    super(message);
    this.name = 'FileError';
  }
}

const DISK = new Map<string, string>([
  ['manifest.txt', 'LAG-441-XA\nABJ-772-KM'],
  ['empty.txt', ''],
]);

function readFileSync(path: string): string {
  const found = DISK.get(path);
  if (found === undefined) {
    throw new FileError(`no such file or directory, open '${path}'`, 'ENOENT', path);
  }
  return found;
}

function readOrExplain(path: string): string {
  try {
    const text = readFileSync(path);
    return text === '' ? `${path} is empty` : `${path}: ${text.split('\n').length} line(s)`;
  } catch (err) {
    // Match the code, not the wording of the message.
    if (err instanceof FileError && err.code === 'ENOENT') {
      return `${path} was not found`;
    }
    throw err;
  }
}

console.log(readOrExplain('manifest.txt'));
console.log(readOrExplain('empty.txt'));
console.log(readOrExplain('missing.txt'));

test('an existing file is read', readOrExplain('manifest.txt'), 'manifest.txt: 2 line(s)');
test('an empty file is not the same as a missing one', readOrExplain('empty.txt'), 'empty.txt is empty');
test('a missing file is reported', readOrExplain('missing.txt'), 'missing.txt was not found');
test('the error carries a machine-readable code', (() => {
  try { readFileSync('nope.txt'); return ''; }
  catch (err) { return err instanceof FileError ? err.code : ''; }
})(), 'ENOENT');

Output


      

Adapted from the original, which uses Node's fs. The commented block shows the real call. The habit being taught survives the swap intact: branch on err.code === 'ENOENT' rather than on the message text, because message wording is not a stable API and changes between Node versions. Note too that an empty file and a missing file are different outcomes, and code that conflates them has a bug.

Answers TS Errors 4 in the original set.

5 A ValidationException class of your own

easy

Write an error class extending the built-in Error, then throw and catch it with your own message.

Stuck? Three hints, smallest first
  1. 1. Call super(message) first, then set this.name so logs read correctly.
  2. 2. Carry structured data as extra fields, such as which field failed and what value arrived.
  3. 3. Catch by type with instanceof, never by comparing message text.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class ValidationException extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly received: unknown,
  ) {
    super(message);
    this.name = 'ValidationException';   // this is what shows up in logs
  }
}

interface Booking {
  reference: string;
  seats: number;
}

function validate(input: { reference?: string; seats?: number }): Booking {
  if (typeof input.reference !== 'string' || input.reference.length === 0) {
    throw new ValidationException('reference is required', 'reference', input.reference);
  }
  if (typeof input.seats !== 'number' || !Number.isInteger(input.seats) || input.seats < 1) {
    throw new ValidationException('seats must be a whole number of 1 or more', 'seats', input.seats);
  }
  return { reference: input.reference, seats: input.seats };
}

function check(input: { reference?: string; seats?: number }): string {
  try {
    const booking = validate(input);
    return `ok: ${booking.reference} x${booking.seats}`;
  } catch (err) {
    if (err instanceof ValidationException) {
      return `${err.name} on "${err.field}": ${err.message} (got ${JSON.stringify(err.received)})`;
    }
    throw err;
  }
}

console.log(check({ reference: 'BK-0001', seats: 2 }));
console.log(check({ seats: 2 }));
console.log(check({ reference: 'BK-0002', seats: 0 }));

test('a valid booking', check({ reference: 'BK-0001', seats: 2 }), 'ok: BK-0001 x2');
test('a missing reference names the field', check({ seats: 2 }).includes('on "reference"'), true);
test('a bad seat count names that field', check({ reference: 'BK-2', seats: 0 }).includes('on "seats"'), true);
test('it is still an Error', new ValidationException('x', 'f', 1) instanceof Error, true);
test('and it is its own type', new ValidationException('x', 'f', 1) instanceof ValidationException, true);
test('the name is set for logs', new ValidationException('x', 'f', 1).name, 'ValidationException');

Output


      

Three things make a custom error worth the five lines: instanceof lets a caller handle exactly this failure, the extra fields carry data a message cannot, and setting this.name means your log says ValidationException rather than the useless Error.

Answers TS Errors 5 in the original set.

6 An ApiError for a failed request

fair

Fetch data from an API and use a custom error class for request failures. Adapted: no network here, so a fake responder stands in and fails in the same shapes a real one does.

Stuck? Three hints, smallest first
  1. 1. Carry the HTTP status on the error, since the caller usually branches on it.
  2. 2. A 404 and a 500 deserve different handling: one is the caller’s fault, one is not.
  3. 3. fetch only rejects on a network failure, not on a 404. You must check response.ok yourself.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class ApiError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    public readonly url: string,
  ) {
    super(message);
    this.name = 'ApiError';
  }

  get isClientFault(): boolean { return this.status >= 400 && this.status < 500; }
  get isServerFault(): boolean { return this.status >= 500; }
}

// The real version would be:
//   const response = await fetch(url);
//   if (!response.ok) throw new ApiError(response.statusText, response.status, url);
//   return await response.json();
//
// Note the trap: fetch does NOT reject on a 404. Checking response.ok is on you.

type Payload = { plate: string; seatsLeft: number };

const ROUTES = new Map<string, { status: number; body?: Payload }>([
  ['/bus/LAG-441-XA', { status: 200, body: { plate: 'LAG-441-XA', seatsLeft: 9 } }],
  ['/bus/UNKNOWN', { status: 404 }],
  ['/bus/BROKEN', { status: 500 }],
]);

function request(url: string): Payload {
  const route = ROUTES.get(url);
  if (route === undefined) throw new ApiError('no route', 404, url);
  if (route.status !== 200 || route.body === undefined) {
    throw new ApiError(`request failed with ${route.status}`, route.status, url);
  }
  return route.body;
}

function load(url: string): string {
  try {
    const data = request(url);
    return `${data.plate}: ${data.seatsLeft} seats left`;
  } catch (err) {
    if (err instanceof ApiError) {
      if (err.isClientFault) return `not found: ${err.url}`;
      if (err.isServerFault) return `server trouble on ${err.url}, try again shortly`;
    }
    throw err;
  }
}

console.log(load('/bus/LAG-441-XA'));
console.log(load('/bus/UNKNOWN'));
console.log(load('/bus/BROKEN'));

test('a good response', load('/bus/LAG-441-XA'), 'LAG-441-XA: 9 seats left');
test('a 404 is the caller\'s problem', load('/bus/UNKNOWN'), 'not found: /bus/UNKNOWN');
test('a 500 is not', load('/bus/BROKEN'), 'server trouble on /bus/BROKEN, try again shortly');
test('the status is on the error', (() => { try { request('/bus/BROKEN'); return 0; } catch (e) { return e instanceof ApiError ? e.status : 0; } })(), 500);

Output


      

Adapted from the original, which calls a live API. The lesson that matters is unchanged and is written in the comment: fetch resolves happily on a 404, so a request that "worked" can still have failed. You have to check response.ok and throw yourself. The isClientFault and isServerFault getters put the retry decision on the error, where it belongs.

Answers TS Errors 6 in the original set.

7 A DatabaseError around a query

fair

Perform a database operation and use a custom error class for database failures, logging what went wrong. Adapted: an in-memory table stands in for the database.

Stuck? Three hints, smallest first
  1. 1. Keep the original failure on the error as a cause, so nothing is lost.
  2. 2. JavaScript’s Error accepts { cause } as a second argument.
  3. 3. Log at the boundary only. Logging in every layer produces the same error four times.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class DatabaseError extends Error {
  constructor(message: string, public readonly query: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = 'DatabaseError';
  }
}

// The stand-in table.
const SEATS = new Map<string, string>([
  ['1A', 'BK-0001'],
  ['1B', 'BK-0001'],
]);

let connected = true;

function query(sql: string, seat: string): string | null {
  if (!connected) {
    // A low-level failure, wrapped so callers get one error type.
    const underlying = new Error('connection refused');
    throw new DatabaseError('cannot reach the database', sql, { cause: underlying });
  }
  if (!sql.startsWith('SELECT')) {
    throw new DatabaseError(`unsupported statement: ${sql}`, sql);
  }
  return SEATS.get(seat) ?? null;
}

const log: string[] = [];

function whoHasSeat(seat: string): string {
  try {
    const reference = query('SELECT reference FROM seats WHERE seat = ?', seat);
    return reference === null ? `${seat} is free` : `${seat} is held by ${reference}`;
  } catch (err) {
    if (err instanceof DatabaseError) {
      // Log once, here at the boundary, with the cause if there was one.
      const because = err.cause instanceof Error ? ` (caused by: ${err.cause.message})` : '';
      log.push(`${err.name}: ${err.message}${because}`);
      return `${seat}: lookup unavailable`;
    }
    throw err;
  }
}

console.log(whoHasSeat('1A'));
console.log(whoHasSeat('3C'));

connected = false;
console.log(whoHasSeat('1A'));
connected = true;

for (const line of log) console.log('LOG', line);

test('a held seat', whoHasSeat('1A'), '1A is held by BK-0001');
test('a free seat', whoHasSeat('3C'), '3C is free');
test('one failure was logged', log.length, 1);
test('the log kept the underlying cause', log[0]?.includes('connection refused'), true);
test('the caller got a usable answer, not a crash', (() => { connected = false; const r = whoHasSeat('1B'); connected = true; return r; })(), '1B: lookup unavailable');

Output


      

Adapted from the original, which uses a real database. Two habits worth keeping. { cause } is standard JavaScript and lets you wrap a low-level failure in a meaningful one without throwing away the detail. And the logging happens once, at the boundary, rather than at every level, which is what stops a single fault filling your log four times.

Answers TS Errors 7 in the original set.

8 An InputError for user input

easy

Write a custom error for invalid input and use it to validate what a user submitted, showing a helpful message for each problem.

Stuck? Three hints, smallest first
  1. 1. Collect every problem before reporting, rather than stopping at the first one.
  2. 2. A form that reports one error at a time is a bad form.
  3. 3. Carry the list of problems on the error so the caller can render them all.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class InputError extends Error {
  constructor(public readonly problems: string[]) {
    super(`${problems.length} problem(s) with the input`);
    this.name = 'InputError';
  }
}

interface Form {
  name?: string;
  email?: string;
  seats?: number;
}

function validate(form: Form): { name: string; email: string; seats: number } {
  const problems: string[] = [];

  if (typeof form.name !== 'string' || form.name.trim().length < 2) {
    problems.push('name must be at least 2 characters');
  }
  if (typeof form.email !== 'string' || !form.email.includes('@')) {
    problems.push('email must contain @');
  }
  if (typeof form.seats !== 'number' || !Number.isInteger(form.seats) || form.seats < 1) {
    problems.push('seats must be a whole number of 1 or more');
  }

  // Report everything at once, not just the first fault.
  if (problems.length > 0) throw new InputError(problems);

  return { name: form.name!.trim(), email: form.email!, seats: form.seats! };
}

function submit(form: Form): string {
  try {
    const clean = validate(form);
    return `booked ${clean.seats} for ${clean.name}`;
  } catch (err) {
    if (err instanceof InputError) return `rejected:\n  - ${err.problems.join('\n  - ')}`;
    throw err;
  }
}

console.log(submit({ name: 'Amaka Obi', email: 'amaka@example.com', seats: 2 }));
console.log(submit({ name: 'A', email: 'nope', seats: 0 }));

const threeProblems = (() => {
  try { validate({}); return 0; }
  catch (err) { return err instanceof InputError ? err.problems.length : 0; }
})();

test('a good form', submit({ name: 'Amaka Obi', email: 'amaka@example.com', seats: 2 }), 'booked 2 for Amaka Obi');
test('an empty form reports all three faults', threeProblems, 3);
test('the name is trimmed', validate({ name: '  Bola  ', email: 'b@x.com', seats: 1 }).name, 'Bola');
test('one bad field is reported alone', (() => { try { validate({ name: 'Amaka', email: 'x@y.com', seats: 0 }); return 0; } catch (e) { return e instanceof InputError ? e.problems.length : 0; } })(), 1);

Output


      

The design choice here matters more than the class. Collecting all the problems and throwing once means the user fixes their form in one pass. Throwing on the first fault makes them submit, fix, submit, fix. The error carries problems as an array precisely so the caller can render the whole list.

Answers TS Errors 8 in the original set.

9 One fault, handled at three levels

hard

Read data and handle failures at more than one level of your code, deciding at each level whether to deal with the fault or pass it up. Adapted: an in-memory store stands in for the file system.

Stuck? Three hints, smallest first
  1. 1. The rule: handle an error at the level that can actually do something about it.
  2. 2. A low level should add context and rethrow; only the top level should decide what the user sees.
  3. 3. Wrapping with { cause } keeps the original while adding your own meaning.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class ReadError extends Error {
  constructor(message: string, public readonly path: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = 'ReadError';
  }
}

const STORE = new Map<string, string>([
  ['fleet.json', '[{"plate":"LAG-441-XA","seatsLeft":9}]'],
  ['broken.json', '{ not valid json'],
]);

// Level 1: the raw read. Knows nothing about meaning.
function rawRead(path: string): string {
  const text = STORE.get(path);
  if (text === undefined) throw new ReadError('not found', path);
  return text;
}

// Level 2: parsing. Adds context, then passes the fault upward.
interface Bus { plate: string; seatsLeft: number }

function loadFleet(path: string): Bus[] {
  const text = rawRead(path);                 // a not-found error travels through
  try {
    return JSON.parse(text) as Bus[];
  } catch (err) {
    // Wrap: keep the original, add what we were trying to do.
    throw new ReadError('file is not valid JSON', path, { cause: err });
  }
}

// Level 3: the boundary. The only level that decides what a human sees.
function summarise(path: string): string {
  try {
    const fleet = loadFleet(path);
    const seats = fleet.reduce((n, b) => n + b.seatsLeft, 0);
    return `${fleet.length} bus(es), ${seats} seats free`;
  } catch (err) {
    if (err instanceof ReadError) {
      const because = err.cause instanceof Error ? ` [${err.cause.name}]` : '';
      return `could not read ${err.path}: ${err.message}${because}`;
    }
    throw err;
  }
}

console.log(summarise('fleet.json'));
console.log(summarise('broken.json'));
console.log(summarise('absent.json'));

test('the good path', summarise('fleet.json'), '1 bus(es), 9 seats free');
test('a parse fault is explained', summarise('broken.json').startsWith('could not read broken.json: file is not valid JSON'), true);
test('and it kept the underlying SyntaxError', summarise('broken.json').includes('SyntaxError'), true);
test('a missing file travelled up untouched', summarise('absent.json'), 'could not read absent.json: not found');

Output


      

The shape to take away: low levels add context, the boundary decides. rawRead throws and does not apologise. loadFleet catches only the parse fault, wraps it with what it was doing, and lets the not-found error pass straight through. Only summarise turns anything into a sentence for a human. Try-catch at every level would just produce the same message three times.

Answers TS Errors 9 in the original set.

10 Several requests in a row, with every fault recorded

hard

Make several requests one after another. Handle a failure from each, pass it up to the caller, and log every one. Adapted: a fake responder stands in for the network.

Stuck? Three hints, smallest first
  1. 1. Decide first whether one failure should stop the run or not. The two designs are very different.
  2. 2. Promise.allSettled is the built-in for "try everything and tell me what happened".
  3. 3. Keep the fake responder synchronous-but-async so top-level await works and the output stays predictable.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class RequestError extends Error {
  constructor(message: string, public readonly url: string) {
    super(message);
    this.name = 'RequestError';
  }
}

const OK = new Set(['/a', '/b', '/d']);

async function request(url: string): Promise<string> {
  // Resolved immediately, so the output order is predictable.
  if (!OK.has(url)) throw new RequestError('request failed', url);
  return `payload from ${url}`;
}

const log: string[] = [];

// Design one: stop at the first failure. Right when later steps depend on earlier ones.
async function sequentialStopOnFirst(urls: string[]): Promise<string[]> {
  const out: string[] = [];
  for (const url of urls) {
    try {
      out.push(await request(url));
    } catch (err) {
      if (err instanceof RequestError) log.push(`${err.name}: ${err.url}`);
      throw err;                        // passed up to the caller
    }
  }
  return out;
}

// Design two: try them all, report everything. Right when they are independent.
async function sequentialTryAll(urls: string[]): Promise<{ ok: string[]; failed: string[] }> {
  const ok: string[] = [];
  const failed: string[] = [];
  for (const url of urls) {
    try {
      ok.push(await request(url));
    } catch (err) {
      if (err instanceof RequestError) {
        log.push(`${err.name}: ${err.url}`);
        failed.push(err.url);
      } else throw err;
    }
  }
  return { ok, failed };
}

const urls = ['/a', '/b', '/c', '/d'];

let stoppedAt = '';
try {
  await sequentialStopOnFirst(urls);
} catch (err) {
  stoppedAt = err instanceof RequestError ? err.url : '';
}

const all = await sequentialTryAll(urls);

console.log('stop-on-first gave up at', stoppedAt);
console.log('try-all succeeded for', all.ok.length, 'and failed for', all.failed);
for (const line of log) console.log('LOG', line);

test('stop-on-first stopped at the bad one', stoppedAt, '/c');
test('try-all got the three good ones', all.ok.length, 3);
test('try-all recorded the bad one', all.failed, ['/c']);
test('every failure was logged', log.length, 2);
test('the good payloads are intact', all.ok[0], 'payload from /a');

Output


      

The two designs are the actual content. Stop on the first failure when step three needs step two’s answer. Try everything and report when the calls are independent, because failing the whole batch over one bad URL loses three good results. In real code the second is often Promise.allSettled, which runs them at the same time as well.

Answers TS Errors 10 in the original set.

11 A workflow where each step has its own error type

hard

Simulate a workflow of several steps. Give each step its own error class, pass failures up, and log which step failed.

Stuck? Three hints, smallest first
  1. 1. A shared base class lets a caller catch the whole family with one instanceof.
  2. 2. Put the step name on the base class so logging is uniform.
  3. 3. Run the same workflow with different inputs so each step fails at least once.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// A shared base, so one instanceof catches the whole family.
abstract class WorkflowError extends Error {
  constructor(message: string, public readonly step: string) {
    super(message);
    this.name = new.target.name;      // each subclass gets its own name free
  }
}

class ValidateError extends WorkflowError {
  constructor(message: string) { super(message, 'validate'); }
}
class ReserveError extends WorkflowError {
  constructor(message: string) { super(message, 'reserve'); }
}
class PayError extends WorkflowError {
  constructor(message: string) { super(message, 'pay'); }
}

interface Request { seats: number; card: string }

const AVAILABLE = 3;

function validate(req: Request): Request {
  if (req.seats < 1) throw new ValidateError('at least one seat is required');
  return req;
}

function reserve(req: Request): number {
  if (req.seats > AVAILABLE) throw new ReserveError(`only ${AVAILABLE} seats left`);
  return req.seats;
}

function pay(req: Request, seats: number): string {
  if (!req.card.startsWith('4')) throw new PayError('card was declined');
  return `paid for ${seats} seat(s)`;
}

const log: string[] = [];

function runWorkflow(req: Request): string {
  try {
    const valid = validate(req);
    const seats = reserve(valid);
    return pay(valid, seats);
  } catch (err) {
    if (err instanceof WorkflowError) {
      // One handler covers all three, because they share a base.
      log.push(`failed at "${err.step}" (${err.name}): ${err.message}`);
      return `could not complete: ${err.message}`;
    }
    throw err;
  }
}

console.log(runWorkflow({ seats: 2, card: '4111' }));
console.log(runWorkflow({ seats: 0, card: '4111' }));
console.log(runWorkflow({ seats: 9, card: '4111' }));
console.log(runWorkflow({ seats: 2, card: '9999' }));

for (const line of log) console.log('LOG', line);

test('the happy path', runWorkflow({ seats: 2, card: '4111' }), 'paid for 2 seat(s)');
test('validate failed', log[0]?.includes('failed at "validate"'), true);
test('reserve failed', log[1]?.includes('failed at "reserve"'), true);
test('pay failed', log[2]?.includes('failed at "pay"'), true);
test('each subclass reported its own name', log[2]?.includes('PayError'), true);
test('three failures logged', log.length, 3);

Output


      

this.name = new.target.name is the trick worth stealing. new.target is the class new was actually called on, so each subclass gets the right name in its logs without repeating a line in every constructor. The shared WorkflowError base means the caller writes one instanceof instead of three.

Answers TS Errors 11 in the original set.

12 A transaction that rolls back when a step fails

hard

Interact with a database and perform a transaction, handling failures throughout so that a failed transaction is rolled back and leaves nothing behind. Adapted: an in-memory table with a snapshot stands in for the database.

Stuck? Three hints, smallest first
  1. 1. Take a copy of the state before you start, and restore it if anything throws.
  2. 2. finally is the right place for cleanup, because it runs whether or not there was a fault.
  3. 3. The test that matters is that the data is unchanged after a failure, not just that an error was thrown.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

class TransactionError extends Error {
  constructor(message: string, public readonly rolledBack: boolean) {
    super(message);
    this.name = 'TransactionError';
  }
}

// The stand-in table: seat -> booking reference.
let seats = new Map<string, string>([['1A', 'BK-0001']]);

const audit: string[] = [];

function hold(seat: string, reference: string): void {
  const taken = seats.get(seat);
  if (taken !== undefined) throw new Error(`${seat} already held by ${taken}`);
  seats.set(seat, reference);
}

/** Run several holds as one unit: either all of them stick, or none do. */
function transaction(reference: string, wanted: string[]): string {
  const snapshot = new Map(seats);       // the rollback point
  let committed = false;

  try {
    for (const seat of wanted) hold(seat, reference);
    committed = true;
    return `${reference}: held ${wanted.join(', ')}`;
  } catch (err) {
    const because = err instanceof Error ? err.message : 'unknown';
    throw new TransactionError(`${reference} failed: ${because}`, true);
  } finally {
    // finally runs on both paths, which is exactly what cleanup needs.
    if (!committed) {
      seats = snapshot;
      audit.push(`rolled back ${reference}`);
    } else {
      audit.push(`committed ${reference}`);
    }
  }
}

console.log(transaction('BK-0002', ['2A', '2B']));
console.log('seats now:', [...seats.keys()].join(', '));

let failure = '';
try {
  // 3A is free but 1A is taken, so the whole thing must come undone.
  transaction('BK-0003', ['3A', '1A']);
} catch (err) {
  failure = err instanceof TransactionError ? err.message : '';
}

console.log('failure:', failure);
console.log('seats after the failure:', [...seats.keys()].join(', '));
for (const line of audit) console.log('AUDIT', line);

test('a good transaction commits', seats.get('2A'), 'BK-0002');
test('both seats of it stuck', seats.get('2B'), 'BK-0002');
test('the failing one reported why', failure.includes('already held by BK-0001'), true);
test('and it rolled back the seat it had already taken', seats.has('3A'), false);
test('the table is exactly as it was', [...seats.keys()].sort(), ['1A', '2A', '2B']);
test('the audit recorded both outcomes', audit, ['committed BK-0002', 'rolled back BK-0003']);

Output


      

Adapted from the original, which uses a real database and its transaction support. The important assertion is the fourth: 3A was successfully held before 1A failed, and after the rollback it is free again. A half-applied transaction is worse than a rejected one, and finally is what guarantees the cleanup runs whichever way the function exits.

Answers TS Errors 12 in the original set.

The exercise list follows w3resource's TypeScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.