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
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.
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
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.
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
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.
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
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
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. #privatefields are enforced by the language; reaching one from outside is a syntax error. Getters expose them read-only.extendsandsuperfor 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
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
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.
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.
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.
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.
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.
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.
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
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.
throwjumps to the nearestcatch; uncaught, it ends the program.finallyalways runs. Use it for cleanup; neverreturnfrom 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 }). AggregateErrorcarries 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
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
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.
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
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
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
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 hasnext(), which gives{ value, done }. for...of, spread, destructuring andArray.fromall use that protocol.function*andyieldwrite 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.