Emmanuel Akinfulubi

Classes and errors all 3 written

Prototypes and the classes built on them, errors you throw on purpose, and the iteration protocol behind every for...of.

17 Prototypes and classes

A class is a template for making objects that share behaviour: many accounts, each with its own balance, all with the same deposit method. In JavaScript that sharing is done by prototypes, and classes are a tidy way to write them. Seeing the prototype first makes everything about classes less mysterious.

Every object has a prototype

Where toString comes from

ran in node · runs here too

example.mjs

const plain = { a: 1 };

console.log(Object.getPrototypeOf(plain) === Object.prototype);
console.log(Object.hasOwn(plain, "a"), Object.hasOwn(plain, "toString"));
console.log(typeof plain.toString, "<- found on the prototype, not on plain");

const bare = Object.create(null);    // an object with no prototype at all
console.log(typeof bare.toString);

check("a plain object inherits from Object.prototype", Object.getPrototypeOf(plain), Object.prototype);
check("toString is inherited, not its own", Object.hasOwn(plain, "toString"), false);
check("Object.create(null) inherits nothing", typeof bare.toString, "undefined");

What node printed, when the build ran it

node example.mjs

true
true false
function <- found on the prototype, not on plain
undefined
ok   a plain object inherits from Object.prototype
ok   toString is inherited, not its own
ok   Object.create(null) inherits nothing

When you read a property, JavaScript looks on the object itself first. If it is not there, it looks on the object's prototype, then that object's prototype, and so on up the prototype chain until it finds it or runs out. That is how {} has a toString it never defined — and why lesson 15's word counter already had a constructor.

Sharing a method through a prototype

ran in node · runs here too

example.mjs

const animal = {
  describe() {
    return `${this.name} says ${this.sound}`;
  },
};

const dog = Object.create(animal);    // dog's prototype is animal
dog.name = "Rex";
dog.sound = "woof";

console.log(dog.describe());
console.log(Object.keys(dog), "<- describe is not dog's own property");

check("the method was found on the prototype", dog.describe(), "Rex says woof");
check("the prototype is animal", Object.getPrototypeOf(dog) === animal, true);

What node printed, when the build ran it

node example.mjs

Rex says woof
[ 'name', 'sound' ] <- describe is not dog's own property
ok   the method was found on the prototype
ok   the prototype is animal

A class

Account

ran in node · runs here too

example.mjs

class Account {
  owner;               // a field: every instance gets its own
  balance = 0;

  constructor(owner, opening = 0) {
    this.owner = owner;
    this.balance = opening;
  }

  deposit(amount) {    // a method: shared by every instance, via the prototype
    this.balance += amount;
    return this.balance;
  }
}

const a = new Account("Ada", 100);
const b = new Account("Alan");
a.deposit(50);

console.log(a, b);
console.log(a.deposit === b.deposit, Object.hasOwn(a, "deposit"));

check("each instance has its own balance", [a.balance, b.balance], [150, 0]);
check("but they share one deposit function", a.deposit === b.deposit, true);
check("which lives on the prototype", Object.getPrototypeOf(a) === Account.prototype, true);

What node printed, when the build ran it

node example.mjs

Account { owner: 'Ada', balance: 150 } Account { owner: 'Alan', balance: 0 }
true false
ok   each instance has its own balance
ok   but they share one deposit function
ok   which lives on the prototype

Node prints an instance with its class name in front — Account { … } — which is how you tell at a glance what kind of object you are looking at. The fields are the instance's own data. The methods are written once, on Account.prototype, and every instance finds them through the chain.

A class is a function

ran in node · runs here too

example.mjs

class Account {}

console.log(typeof Account);
console.log(Account.prototype.constructor === Account);

check("a class is a function underneath", typeof Account, "function");

What node printed, when the build ran it

node example.mjs

function
true
ok   a class is a function underneath

Calling a class without new

dies with TypeError

example.mjs

class Account {
  constructor(owner) {
    this.owner = owner;
  }
}

const acct = Account("Ada");      // forgot new

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:7
const acct = Account("Ada");      // forgot new
             ^

TypeError: Class constructor Account cannot be invoked without 'new'
    at file:///app/example.mjs:7:14
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v26.7.0

Private fields

A field whose name starts with # can only be read or written by code inside the class. Combined with a getter, that gives state the outside world can see but not change.

Private state, enforced by the language

ran in node · runs here too

example.mjs

class Account {
  #balance = 0;

  deposit(amount) {
    if (amount <= 0) throw new RangeError("deposit must be positive");
    this.#balance += amount;
  }

  get balance() {
    return this.#balance;
  }

  static isAccount(value) {
    return #balance in value;
  }
}

const acct = new Account();
acct.deposit(25);

console.log(acct.balance, Object.keys(acct));
console.log(Account.isAccount(acct), Account.isAccount({ balance: 5 }));

check("the balance is readable through the getter", acct.balance, 25);
check("but #balance is not an ordinary property", Object.keys(acct), []);
check("#field in obj tells a real Account from a lookalike", Account.isAccount({ balance: 5 }), false);

What node printed, when the build ran it

node example.mjs

25 []
true false
ok   the balance is readable through the getter
ok   but #balance is not an ordinary property
ok   #field in obj tells a real Account from a lookalike

Reaching for a private field from outside

dies with SyntaxError

example.mjs

class Account {
  #balance = 100;
}

const acct = new Account();
console.log("this line never runs");
console.log(acct.#balance);

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:7
console.log(acct.#balance);
                ^

SyntaxError: Private field '#balance' must be declared in an enclosing class
    at compileSourceTextModule (node:internal/modules/esm/utils:355:16)
    at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:90:18)
    at #translate (node:internal/modules/esm/loader:441:20)
    at afterLoad (node:internal/modules/esm/loader:509:29)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)
    at afterResolve (node:internal/modules/esm/loader:614:52)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:620:12)
    at node:internal/modules/esm/loader:639:32
    at TracingChannel.tracePromise (node:diagnostics_channel:538:22)

Node.js v26.7.0

Notice that "this line never runs" never printed. Touching a private field from outside its class is not an error when the line runs; it is a syntax error, found before the file runs at all. It is the strongest privacy JavaScript offers, and unlike a naming convention such as _balance, it cannot be bypassed.

Inheritance

extends and super

ran in node · runs here too

example.mjs

class Shape {
  constructor(name) {
    this.name = name;
  }
  area() {
    return 0;
  }
  describe() {
    return `${this.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super("circle");         // must run before this is used
    this.radius = radius;
  }
  area() {                   // overrides Shape's version
    return Math.PI * this.radius ** 2;
  }
}

const c = new Circle(2);
console.log(c.describe());
console.log(c instanceof Circle, c instanceof Shape);

check("describe (from Shape) called area (from Circle)", c.describe(), "circle with area 12.57");
check("a Circle is also a Shape", c instanceof Shape, true);

What node printed, when the build ran it

node example.mjs

circle with area 12.57
true true
ok   describe (from Shape) called area (from Circle)
ok   a Circle is also a Shape

describe is defined on Shape, and calls this.area() — which finds Circle's version first, because the lookup starts at the object. That is the whole point of overriding. super(...) runs the parent's constructor, and must come before any use of this in the child's.

Prefer shallow hierarchies. One level of extends — typically your own error types (next lesson) or a subclass of a Node class such as EventEmitter (lesson 33) — is common and clear. Deep chains of classes are hard to follow; most of the time a function, or an object that holds another object, is simpler than one that inherits from it.

  • A property not found on an object is looked for on its prototype, then up the chain.
  • A class's fields belong to each instance; its methods live once, on the prototype.
  • A class is a function, and must be called with new.
  • #private fields are enforced by the language; reaching one from outside is a syntax error. Getters expose them read-only.
  • extends and super for inheritance. Keep hierarchies shallow.

18 Throwing, catching, and errors of your own

An error is how a function says "I cannot do what you asked". This lesson is about doing that deliberately — choosing what to throw, catching only what you can actually handle, and wrapping low-level failures in errors that make sense to whoever reads them later.

throw, try, catch

The basic shape

ran in node · runs here too

example.mjs

function withdraw(balance, amount) {
  if (amount > balance) {
    throw new Error(`cannot withdraw ${amount} from a balance of ${balance}`);
  }
  return balance - amount;
}

let outcome;
try {
  outcome = withdraw(50, 80);
  console.log("this line is skipped");
} catch (err) {
  outcome = `refused: ${err.message}`;
}

console.log(outcome);

check("throw jumped straight to catch", outcome, "refused: cannot withdraw 80 from a balance of 50");

What node printed, when the build ran it

node example.mjs

refused: cannot withdraw 80 from a balance of 50
ok   throw jumped straight to catch

throw stops the function immediately and travels up through every caller until something catches it. If nothing does, the program dies and prints the error — which is what every panel in lesson 3 was. try marks the code you are prepared for failing, and catch receives what was thrown.

finally

Cleanup that always happens

ran in node · runs here too

example.mjs

const log = [];

function readConfig(shouldFail) {
  log.push("open");
  try {
    if (shouldFail) throw new Error("bad config");
    return "config";
  } finally {
    log.push("close");      // runs whether try returned or threw
  }
}

readConfig(false);
try { readConfig(true); } catch {}

console.log(log);

check("finally ran after a return and after a throw", log, ["open", "close", "open", "close"]);

What node printed, when the build ran it

node example.mjs

[ 'open', 'close', 'open', 'close' ]
ok   finally ran after a return and after a throw

finally is for cleanup — closing a file, releasing a lock, stopping a timer — that must happen however the try block ended. It runs after a return, and after a throw, before the error carries on upwards.

Never return from finally

ran in node · runs here too

example.mjs

function surprising() {
  try {
    throw new Error("this error disappears");
  } finally {
    return "finally won";      // a return here overrides everything
  }
}

console.log(surprising());

check("a return in finally swallowed the error", surprising(), "finally won");

What node printed, when the build ran it

node example.mjs

finally won
ok   a return in finally swallowed the error

A return inside finally replaces whatever the try was doing — including a thrown error, which vanishes without a trace. Use finally only for cleanup, and never return from it.

Always throw an Error

JavaScript lets you throw anything. Only an Error records where it was created.

Throwing a string

ran at build time

example.mjs

function loadUser(id) {
  throw "user not found";          // a bare string
}

loadUser(7);

What node printed, when the build ran it

node example.mjs

node:internal/modules/run_main:107
    triggerUncaughtException(
    ^
user not found
(Use `node --trace-uncaught ...` to show where the exception was thrown)

Node.js v26.7.0

Compare that with any error in lesson 3. There is no line, no file, no stack — the location Node shows is inside its own machinery, because a string has nowhere to keep one. In a large program you would have no idea which of a hundred loadUser calls failed.

An Error carries its location

ran at build time

example.mjs

let caught;
try {
  throw new Error("user not found");
} catch (err) {
  caught = err;
}

console.log(caught instanceof Error, Error.isError(caught), Error.isError("user not found"));
console.log(caught.stack.split("\n")[1].trim());

check("an Error knows where it was made", caught.stack.includes("example.mjs:3"), true);
check("Error.isError tells a real error from a string", Error.isError("oops"), false);

What node printed, when the build ran it

node example.mjs

true true false
at file:///app/example.mjs:3:9
ok   an Error knows where it was made
ok   Error.isError tells a real error from a string

Errors of your own

A program usually has a few kinds of failure that callers need to tell apart: not found, not allowed, invalid input. A small class for each lets a catch decide what to do by type, rather than by reading the message.

Choosing what to do by type

ran in node · runs here too

example.mjs

class NotFoundError extends Error {
  constructor(what, options) {
    super(`${what} was not found`, options);
    this.name = "NotFoundError";   // so the name prints correctly
    this.status = 404;
  }
}

class ValidationError extends Error {
  constructor(field, problem) {
    super(`${field}: ${problem}`);
    this.name = "ValidationError";
    this.field = field;
    this.status = 400;
  }
}

function respond(fn) {
  try {
    return { status: 200, body: fn() };
  } catch (err) {
    if (err instanceof NotFoundError || err instanceof ValidationError) {
      return { status: err.status, body: err.message };
    }
    throw err;                      // anything else is a real bug: let it through
  }
}

console.log(respond(() => "ok"));
console.log(respond(() => { throw new NotFoundError("order 42"); }));
console.log(respond(() => { throw new ValidationError("email", "must contain @"); }));

check("not found becomes a 404", respond(() => { throw new NotFoundError("x"); }).status, 404);
check("invalid input becomes a 400", respond(() => { throw new ValidationError("e", "bad"); }).status, 400);

What node printed, when the build ran it

node example.mjs

{ status: 200, body: 'ok' }
{ status: 404, body: 'order 42 was not found' }
{ status: 400, body: 'email: must contain @' }
ok   not found becomes a 404
ok   invalid input becomes a 400

Look at the last line of respond's catch. It handles the two errors it knows how to turn into a response, and re-throws everything else. A catch that swallows every error hides real bugs — a typo becomes a silent wrong answer instead of a crash with a line number. Catch what you can handle; let the rest through.

How Node prints your own error

dies with NotFoundError

example.mjs

class NotFoundError extends Error {
  constructor(what) {
    super(`${what} was not found`);
    this.name = "NotFoundError";
    this.status = 404;
  }
}

throw new NotFoundError("order 42");

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:9
throw new NotFoundError("order 42");
      ^

NotFoundError: order 42 was not found
    at file:///app/example.mjs:9:7
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
    at async node:internal/modules/esm/loader:650:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) {
  status: 404
}

Node.js v26.7.0

Node prints your class's name, your message, the stack — and, in braces at the end, any extra properties you gave it, such as status. That is the same block where a system error shows its code.

Wrapping an error with a cause

Often the error you catch is too low-level to be useful on its own: "Unexpected token" means nothing to someone who does not know a config file was being read. Wrap it in an error that says what you were doing, and keep the original as its cause.

An error, and the error that caused it

dies with Error

example.mjs

import { readFileSync, writeFileSync } from "node:fs";

writeFileSync("app.json", "{ port: 8080 }");     // not valid JSON

function loadConfig(path) {
  try {
    return JSON.parse(readFileSync(path, "utf8"));
  } catch (err) {
    throw new Error(`could not load config from ${path}`, { cause: err });
  }
}

loadConfig("app.json");

What node printed as it died, when the build ran it

node example.mjs

file:///app/example.mjs:9
    throw new Error(`could not load config from ${path}`, { cause: err });
          ^

Error: could not load config from app.json
    at loadConfig (file:///app/example.mjs:9:11)
    at file:///app/example.mjs:13:1
    ... 2 lines matching cause stack trace ...
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) {
  [cause]: SyntaxError: Expected property name or '}' in JSON at position 2 (line 1 column 3)
      at JSON.parse (<anonymous>)
      at loadConfig (file:///app/example.mjs:7:17)
      at file:///app/example.mjs:13:1
      at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
      at async node:internal/modules/esm/loader:650:26
      at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
}

Node.js v26.7.0

Both are there: the top error says what the program was trying to do, and the indented [cause] underneath says exactly why it failed, with its own stack. (The line ... 2 lines matching cause stack trace ... is Node saving space: the two errors share the frames below that point, so it prints them once.) Whoever reads this in a log at three in the morning has both halves of the story.

Several errors at once

AggregateError

ran in node · runs here too

example.mjs

const problems = [
  new Error("name is required"),
  new Error("email must contain @"),
];

const err = new AggregateError(problems, "the form has 2 problems");

console.log(err.name, "-", err.message);
for (const p of err.errors) console.log("  -", p.message);

check("AggregateError holds a list of errors", err.errors.length, 2);

What node printed, when the build ran it

node example.mjs

AggregateError - the form has 2 problems
  - name is required
  - email must contain @
ok   AggregateError holds a list of errors

Validation that collects every problem rather than stopping at the first is the everyday use. Promise.any (lesson 26) produces one when every promise fails.

  • throw jumps to the nearest catch; uncaught, it ends the program.
  • finally always runs. Use it for cleanup; never return from it.
  • Always throw an Error. A string has no stack and no location.
  • Give each kind of failure a small class. Catch by type, and re-throw what you cannot handle.
  • Wrap low-level errors with new Error("what I was doing", { cause: err }).
  • AggregateError carries several errors at once.

19 Iterators and generators

for...of works on arrays, strings, Maps and Sets — and on anything else that follows a small protocol. Knowing that protocol lets you make your own objects loopable, and it is the same protocol, with promises added, that Node uses to hand you a file one line at a time.

What for...of actually does

The protocol, by hand

ran in node · runs here too

example.mjs

const colours = ["red", "green"];

// This is what "for (const c of colours)" does underneath:
const iterator = colours[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

check("each next() hands back one value", ["red", "green"][Symbol.iterator]().next(), { value: "red", done: false });
check("and says done when there are no more", iterator.next(), { value: undefined, done: true });

What node printed, when the build ran it

node example.mjs

{ value: 'red', done: false }
{ value: 'green', done: false }
{ value: undefined, done: true }
ok   each next() hands back one value
ok   and says done when there are no more

An iterable is anything with a [Symbol.iterator] method. Calling it returns an iterator: an object with a next() method that gives back { value, done } each time it is called. for...of calls next() until done is true. Spread ([...x]), destructuring and Array.from all use the same protocol.

Making your own object iterable

An iterable Range

ran in node · runs here too

example.mjs

class Range {
  constructor(from, to) {
    this.from = from;
    this.to = to;
  }

  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current > last) return { value: undefined, done: true };
        return { value: current++, done: false };
      },
    };
  }
}

const seen = [];
for (const n of new Range(3, 6)) seen.push(n);

console.log(seen, [...new Range(1, 3)], Math.max(...new Range(1, 10)));

check("for...of used the protocol", seen, [3, 4, 5, 6]);
check("and so do spread and Math.max", Math.max(...new Range(1, 10)), 10);

What node printed, when the build ran it

node example.mjs

[ 3, 4, 5, 6 ] [ 1, 2, 3 ] 10
ok   for...of used the protocol
ok   and so do spread and Math.max

Generators: the same thing, written simply

Writing next() by hand is fiddly. A generator function — function* — writes the iterator for you. Each yield hands out one value and pauses the function right there until the next value is asked for.

function* and yield

ran in node · runs here too

example.mjs

function* range(from, to) {
  for (let n = from; n <= to; n++) {
    yield n;
  }
}

const steps = [];
function* traced() {
  steps.push("started");
  yield "a";
  steps.push("resumed after a");
  yield "b";
  steps.push("finished");
}

const it = traced();
steps.push("created");
it.next();
steps.push("took a");
it.next();
it.next();

console.log([...range(3, 6)]);
console.log(steps);

check("a generator is iterable", [...range(3, 6)], [3, 4, 5, 6]);
check("its body runs only as values are asked for",
      steps, ["created", "started", "took a", "resumed after a", "finished"]);

What node printed, when the build ran it

node example.mjs

[ 3, 4, 5, 6 ]
[ 'created', 'started', 'took a', 'resumed after a', 'finished' ]
ok   a generator is iterable
ok   its body runs only as values are asked for

Look at the order in steps. Creating the generator ran none of its body. Each next() ran it up to the following yield and stopped. That pause is the whole idea.

Laziness: an infinite sequence, safely

Values made only when asked for

ran in node · runs here too

example.mjs

let produced = 0;
function* naturals() {
  let n = 1;
  while (true) {                 // never ends -- and that is fine
    produced++;
    yield n++;
  }
}

function* take(iterable, count) {
  if (count <= 0) return;
  for (const value of iterable) {
    yield value;
    if (--count === 0) return;
  }
}

const firstFive = [...take(naturals(), 5)];
console.log(firstFive, "computed:", produced);

check("the first five naturals", firstFive, [1, 2, 3, 4, 5]);
check("and only five values were ever computed", produced, 5);

What node printed, when the build ran it

node example.mjs

[ 1, 2, 3, 4, 5 ] computed: 5
ok   the first five naturals
ok   and only five values were ever computed

A generator computes nothing until asked, so a sequence with no end is perfectly safe to define; only asking for all of it would never finish. This laziness is what lets Node read a ten-gigabyte log file one line at a time: the lines are produced on demand, and never all held in memory together. That is lesson 35.

Iterator helpers

map, filter and take on an iterator

ran in node · runs here too

example.mjs

let looked = 0;
function* naturals() {
  let n = 1;
  while (true) {
    looked++;
    yield n++;
  }
}

console.log("has iterator helpers:", typeof Iterator.prototype.map === "function");

const firstSquaresOverFifty = naturals()
  .map((n) => n * n)
  .filter((sq) => sq > 50)
  .take(3)
  .toArray();

console.log(firstSquaresOverFifty, "numbers looked at:", looked);

check("map, filter and take on an iterator", firstSquaresOverFifty, [64, 81, 100]);
check("and lazily: it stopped at 10", looked, 10);

What node printed, when the build ran it

node example.mjs

has iterator helpers: true
[ 64, 81, 100 ] numbers looked at: 10
ok   map, filter and take on an iterator
ok   and lazily: it stopped at 10

Iterators now have their own map, filter, take, drop, flatMap and toArray. Unlike the array versions, they are lazy: that chain looked at ten numbers, not infinitely many. They are recent — in V8, and so in Node and Chrome — so check before relying on them in code that must run on an older runtime.

Maps, entries and destructuring use this too

The protocol, everywhere

ran in node · runs here too

example.mjs

const stock = new Map([["apples", 3], ["pears", 0]]);

for (const [fruit, count] of stock) {
  console.log(fruit, count);
}

const [first] = stock;              // destructuring pulls one value from the iterator
const [a, b] = "hi";               // strings are iterable too

console.log(first, a, b);

check("iterating a Map gives [key, value] pairs", first, ["apples", 3]);
check("destructuring a string takes characters", [a, b], ["h", "i"]);

What node printed, when the build ran it

node example.mjs

apples 3
pears 0
[ 'apples', 3 ] h i
ok   iterating a Map gives [key, value] pairs
ok   destructuring a string takes characters
  • An iterable has [Symbol.iterator](); the iterator it returns has next(), which gives { value, done }.
  • for...of, spread, destructuring and Array.from all use that protocol.
  • function* and yield write iterators for you, and pause between values.
  • Generators are lazy, so infinite sequences are safe as long as you take only what you need.
  • Iterator helpers (map, filter, take, …) are the lazy version of the array methods.

Newsletter

New writing, when it lands.

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