The previous lesson ended with inheritance working, but also with a list of annoyances: three lines of ritual (Parent.call(this, …), Object.create(Parent.prototype), restoring constructor) for every relationship between constructors, methods declared far from the constructor with one Constructor.prototype.method = function … per line, and no mechanism to stop you forgetting the new. Since ES2015 the language has offered the class keyword, which solves all of that without changing the mechanism: underneath there are still constructor functions and prototype objects, exactly the ones from 05-01. In this lesson you will learn the complete syntax —constructor, methods, fields, static, extends, super—, you will rewrite the Nómada Tasks model as class Task, class RecurringTask and class Board, you will see the four pillars of object-oriented programming grounded in that code, you will understand why it is almost always better to compose than to inherit, and you will finally, fully understand that class ValidationError extends Error you have been dragging along as a recipe since 02-05.
Contents
- The same class, in both syntaxes
constructorand instance methods- Class fields
staticmethods and fields- The project's
Taskclass extendsandsuper: inheritance without ceremony- Overriding and polymorphism
- The
Boardclass - The four pillars of OOP
- Composition versus inheritance
class ValidationError extends Error, fully explained- Language details that classes change
- Common Mistakes and Tips
- Exercises
- Conclusion
- The same class, in both syntaxes
The best way to see what class brings is to put the two versions side by side. On the left, the constructor from the previous lesson; on the right, its exact equivalent.
// ── CONSTRUCTOR SYNTAX (05-01) ───────────────────────────────────
function Task(data) {
this.id = data.id;
this.title = data.title;
this.status = data.status ?? 'pending';
}
Task.prototype.describe = function () {
return `[${this.id}] ${this.title}`;
};
Task.prototype.isOverdue = function (today) {
return this.dueDate < today && this.status !== 'done';
};// ── CLASS SYNTAX (ES2015) ────────────────────────────────────────
class Task {
constructor(data) {
this.id = data.id;
this.title = data.title;
this.status = data.status ?? 'pending';
}
describe() {
return `[${this.id}] ${this.title}`;
}
isOverdue(today) {
return this.dueDate < today && this.status !== 'done';
}
}And here is the important part: the result is the same mechanism.
console.log(typeof Task); // 'function' ← it is still a function
const t = new Task({ id: 1, title: 'Redesign the multipurpose room' });
console.log(Object.getPrototypeOf(t) === Task.prototype); // true
console.log(Object.hasOwn(t, 'describe')); // false ← it lives on the prototype
console.log(Object.hasOwn(Task.prototype, 'describe')); // trueA
classis syntactic sugar over constructor functions and prototypes. It is not a different object system: it is the same prototype chain written readably, with several fixes applied out of the box.
Those fixes, which we will go through, are the ones in this table:
| Aspect | Constructor | class |
|---|---|---|
| Where methods are declared | Loose, one per line | Inside the block, next to the constructor |
| Are the methods enumerable? | Yes (they pollute for...in) |
No, never |
Calling without new |
Silent, or a confusing TypeError |
Explicit, unavoidable TypeError |
| Inheritance | 3 lines of ritual | extends |
| Calling the parent | Parent.prototype.m.call(this) |
super.m() |
Correct constructor |
Has to be restored by hand | Automatic |
| Strict mode | Has to be requested | Always on inside the class |
| Hoisting | Can be used before it is declared | No: there is a TDZ, as with let |
Classes also exist as an expression, just like the functions in 03-02:
const Task = class { /* … */ }; // anonymous class expression
const Task = class InnerTask { /* … */ }; // named, visible only insideIn practice the declaration form is almost always the one used.
constructor and instance methods
constructor and instance methodsThe constructor block is the function that runs when you use new. It is step 3 of the four you took apart in 05-01: it receives the arguments and fills in the new object through this.
'use strict';
class Person {
constructor(name, role) {
this.name = name;
this.role = role;
}
introduce() { // instance method → goes on the prototype
return `${this.name} (${this.role})`;
}
}
const marta = new Person('Marta', 'coordinator');
console.log(marta.introduce()); // 'Marta (coordinator)'Rules for the constructor:
- There can only be one per class. JavaScript has no constructor overloading; if you need several ways of building, you use
staticfactory methods (section 4). - It is optional. If you do not write one, an empty default is used (or one that calls
super(...args)if the class inherits). - It does not take the word
functionor a comma after it. Inside a class body, members are separated by line breaks, not commas: it is a very typical mistake when coming from the object literals of Module 4.
Instance methods are written with the shorthand syntax you already know from 04-02, and —this is the essential part— they are placed on the prototype automatically, so every instance shares a single copy:
const ivan = new Person('Iván', 'designer');
console.log(marta.introduce === ivan.introduce); // true
- Class fields
Besides the constructor, a class can declare fields: instance properties with an initial value, written directly in the body.
'use strict';
class Counter {
value = 0; // instance field with an initial value
history = []; // careful! see the note below
increment() {
this.value++;
this.history.push(this.value);
return this.value;
}
}
const c1 = new Counter();
const c2 = new Counter();
c1.increment();
c1.increment();
console.log(c1.value, c2.value); // 2 0
console.log(c1.history, c2.history); // [1, 2] []A field runs once per instance, right at the start of the constructor. That is why c1.history and c2.history are different arrays: nothing is shared. This contrasts with the warning in 05-01 about not putting arrays on the prototype, where they would be shared. Fields are own properties:
console.log(Object.hasOwn(c1, 'value')); // true ← field: own property
console.log(Object.hasOwn(c1, 'increment')); // false ← method: on the prototypeFields are especially useful for default values that do not depend on the arguments:
class Task {
tags = [];
reviewer = null;
status = 'pending'; // R5: every task is born pending
constructor(id, title) {
this.id = id;
this.title = title;
}
}| Where to declare the property | When |
|---|---|
Field (status = 'pending';) |
Fixed initial value, independent of the arguments |
Constructor (this.id = data.id;) |
The value comes from the arguments or needs computing |
Fields are initialized before the body of the constructor, so the constructor can overwrite them without any problem.
static methods and fields
static methods and fieldsA static member belongs to the class, not to the instances. It is accessed as Class.member, and inside a static method this is the class itself.
'use strict';
class Task {
static STATUSES = ['pending', 'in-progress', 'done']; // static field (shared constant)
static created = 0; // static field (counter)
constructor(id, title) {
this.id = id;
this.title = title;
Task.created++; // the class counter is updated
}
static isValidStatus(status) { // static method
return Task.STATUSES.includes(status);
}
}
new Task(1, 'Redesign the multipurpose room');
new Task(2, 'Signage for the screen-printing workshop');
console.log(Task.created); // 2
console.log(Task.isValidStatus('in-progress')); // true
console.log(Task.isValidStatus('cancelled')); // false
const t = new Task(3, 'Update the bookings website');
// console.log(t.isValidStatus('done')); // ✗ TypeError: it is not an instance methodThat last commented line is the key difference: statics are not inherited by the instances, they live on the function object.
The most valuable use of statics is factories: methods that build instances in alternative ways, making up for the lack of constructor overloading. Picking up the JSON from 04-08:
'use strict';
class Task {
constructor(data) {
this.id = data.id;
this.title = data.title;
this.assignee = data.assignee ?? null;
this.priority = data.priority ?? 'medium';
this.status = data.status ?? 'pending';
this.tags = data.tags ?? [];
this.estimatedHours = data.estimatedHours;
this.dueDate = data.dueDate;
this.reviewer = data.reviewer ?? null;
}
/** Builds a Task from a JSON string. */
static fromJSON(text) {
let data;
try {
data = JSON.parse(text);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
return new Task(data);
}
/** Builds a quick task with the bare minimum. */
static quick(title, assignee) {
return new Task({ id: Task.nextId(), title, assignee, estimatedHours: 1, dueDate: '2026-12-31' });
}
static lastId = 6; // the canonical backlog goes up to 6
static nextId() {
Task.lastId += 1;
return Task.lastId;
}
}
const fromText = Task.fromJSON('{"id":6,"title":"Carpentry workshop quote","estimatedHours":5,"dueDate":"2026-09-05"}');
console.log(fromText instanceof Task); // true
console.log(fromText.status); // 'pending' ← the default applies just the same
const quick = Task.quick('Buy black screen-printing ink', 'Marta');
console.log(quick.id); // 7That Task.nextId() is the same id generator you solved with a closure in 03-04. Here the state lives in a static field instead of a captured variable; in 05-03 you will see how to protect it so nobody can write Task.lastId = 0 from outside.
- The project's
Task class
Task classWith the syntax covered, here is the definitive version of the model, with the project's business rules inside. Pay special attention to changeStatus, which applies R6 (only the allowed transitions).
'use strict';
const WEIGHTS = { high: 3, medium: 2, low: 1 };
const BADGES = { pending: '○', 'in-progress': '▸', done: '✓' };
class Task {
// ── Class constants ─────────────────────────────────────────────
static STATUSES = ['pending', 'in-progress', 'done'];
static TRANSITIONS = { // R6: the state diagram from 01-08
pending: ['in-progress'],
'in-progress': ['pending', 'done'],
done: []
};
static MAX_HOURS = 40; // R3 and R7
// ── Fields with a default value ─────────────────────────────────
status = 'pending'; // R5
tags = [];
reviewer = null;
constructor(data) {
if (typeof data.title !== 'string' || data.title.trim() === '') {
throw new ValidationError('The title cannot be empty.', 'title', data.title); // R2
}
if (!(data.estimatedHours > 0 && data.estimatedHours <= Task.MAX_HOURS)) {
throw new ValidationError(`Hours must be between 1 and ${Task.MAX_HOURS}.`, 'estimatedHours', data.estimatedHours); // R3
}
this.id = data.id;
this.title = data.title.trim();
this.assignee = data.assignee ?? null; // R8
this.priority = data.priority ?? 'medium';
this.estimatedHours = data.estimatedHours;
this.dueDate = data.dueDate;
if (data.status !== undefined) this.status = data.status;
if (data.tags !== undefined) {
this.tags = [...new Set(data.tags.map((tag) => tag.trim().toLowerCase()))]; // R9
}
if (data.reviewer !== undefined) this.reviewer = data.reviewer;
}
// ── Queries ─────────────────────────────────────────────────────
isOverdue(today) {
return this.dueDate < today && this.status !== 'done'; // R10
}
isOpen() {
return this.status !== 'done';
}
effort() {
return (WEIGHTS[this.priority] ?? 0) * this.estimatedHours;
}
describe(today) {
const badge = BADGES[this.status] ?? '?';
const warning = today !== undefined && this.isOverdue(today) ? ' ⚠ OVERDUE' : '';
return `${badge} [${this.id}] ${this.title} · ${this.assignee ?? 'unassigned'} · ${this.estimatedHours} h${warning}`;
}
// ── Modification with rules ─────────────────────────────────────
changeStatus(next) {
if (!Task.STATUSES.includes(next)) {
throw new ValidationError(`Unknown status: "${next}".`, 'status', next);
}
const allowed = Task.TRANSITIONS[this.status];
if (!allowed.includes(next)) { // R6
throw new ValidationError(
`Transition not allowed: "${this.status}" → "${next}". Allowed: ${allowed.join(', ') || 'none'}.`,
'status', next
);
}
this.status = next;
return this; // fluent interface (04-02)
}
toJSON() { // picking up 04-08
return { ...this };
}
toString() { // picking up 05-01
return `Task#${this.id} "${this.title}"`;
}
}And in use:
const carpentryQuote = new Task({
id: 6, title: 'Carpentry workshop quote', assignee: 'Iván',
priority: 'high', tags: ['Carpentry', 'purchasing', 'carpentry'],
estimatedHours: 5, dueDate: '2026-09-05', reviewer: 'Marta'
});
console.log(carpentryQuote.tags); // [ 'carpentry', 'purchasing' ] ← R9 applied
console.log(carpentryQuote.describe('2026-09-20')); // ○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE
console.log(carpentryQuote.effort()); // 15
carpentryQuote.changeStatus('in-progress'); // ✓ pending → in-progress
console.log(carpentryQuote.status); // 'in-progress'
try {
const other = new Task({ id: 9, title: 'New task', estimatedHours: 4, dueDate: '2026-10-01' });
other.changeStatus('done'); // ✗ pending → done does not exist (R6)
} catch (error) {
console.error(`${error.name} in "${error.field}": ${error.message}`);
// ValidationError in "status": Transition not allowed: "pending" → "done". Allowed: in-progress.
}What you have achieved here is enormous and worth saying out loud: it is no longer possible to have a task in an invalid status. In Module 4, any line of the program could write task.status = 'done' and skip R6. Now there is a method that is the official route. It can still be bypassed by writing straight into the property —you close that hole in 05-03—, but the design intent is already expressed in the code.
extends and super: inheritance without ceremony
extends and super: inheritance without ceremonyAll the ceremony from section 12 of the previous lesson shrinks to a single word.
'use strict';
class RecurringTask extends Task {
static PERIODS = { weekly: 7, fortnightly: 14, monthly: 30 };
constructor(data) {
super(data); // ← equivalent to Task.call(this, data)
this.frequency = data.frequency ?? 'weekly';
this.repeats = 0;
}
nextDate() {
const days = RecurringTask.PERIODS[this.frequency] ?? 7;
const base = new Date(this.dueDate);
base.setDate(base.getDate() + days);
return base.toISOString().slice(0, 10);
}
describe(today) {
return `${super.describe(today)} · repeats ${this.frequency}`; // ← calls the parent's version
}
changeStatus(next) {
super.changeStatus(next); // validates with the parent's rules
if (next === 'done') { // …and adds its own behavior
this.repeats += 1;
this.dueDate = this.nextDate();
this.status = 'pending'; // a recurring task starts over
}
return this;
}
}
const pickup = new RecurringTask({
id: 8, title: 'Collect the screen-printing materials', assignee: 'Marta',
priority: 'low', estimatedHours: 1, dueDate: '2026-09-25', frequency: 'weekly'
});
console.log(pickup.describe());
// ○ [8] Collect the screen-printing materials · Marta · 1 h · repeats weekly
pickup.changeStatus('in-progress').changeStatus('done');
console.log(pickup.dueDate, pickup.status, pickup.repeats);
// '2026-10-02' 'pending' 1
console.log(pickup instanceof RecurringTask); // true
console.log(pickup instanceof Task); // true
console.log(RecurringTask.MAX_HOURS); // 40 ← statics ARE inherited between classessuper has two uses, and it is worth not mixing them up:
| Form | Where it is used | What it does |
|---|---|---|
super(args) |
Only in the constructor |
Runs the parent class's constructor on this |
super.method(args) |
In any method | Calls the parent's version of the method, with this untouched |
And a rule the language enforces harshly: in a class with extends, you must call super(...) before using this.
class Broken extends Task {
constructor(data) {
this.extra = 1; // ✗ ReferenceError: Must call super constructor… before accessing 'this'
super(data);
}
}The reason is consistent with what you know from 05-01: it is the call to super that creates and initializes the parent part of the object. Before it, this literally does not exist yet. If you do not write any constructor in the child class, JavaScript generates an implicit one equivalent to constructor(...args) { super(...args); }, which is exactly what you want 80 % of the time.
The resulting chain is identical to the one you drew by hand in 05-01, but wired up by the language:
flowchart TD
R["pickup<br/>{ id: 8, frequency, repeats… }"] -->|"[[Prototype]]"| TRP["RecurringTask.prototype<br/>{ nextDate, describe, changeStatus }"]
TRP -->|"[[Prototype]]"| TP["Task.prototype<br/>{ isOverdue, effort, describe, changeStatus… }"]
TP -->|"[[Prototype]]"| OP["Object.prototype"]
OP -->|"[[Prototype]]"| N["null"]
TRC["RecurringTask<br/>(the class)"] -.->|"[[Prototype]] · this is how<br/>static members are inherited"| TC["Task<br/>(the class)"]
Notice the dotted arrow: extends links two chains, the prototype one (for instance methods) and the one between the classes themselves (for static members). That is why RecurringTask.MAX_HOURS works without ever being declared there.
- Overriding and polymorphism
When the child class defines a method with the same name as the parent, it overrides it: when walking the chain, the child's is found first. You have already seen that. What is new is the design consequence: polymorphism.
Polymorphism: code that calls the same method on objects of different classes and gets the behavior appropriate to each, with no
ifasking which class it is dealing with.
'use strict';
class BlockedTask extends Task {
constructor(data) {
super(data);
this.reason = data.reason ?? 'unspecified';
}
describe(today) {
return `⛔ ${super.describe(today)} · BLOCKED (${this.reason})`;
}
changeStatus() {
throw new ValidationError('A blocked task cannot change status.', 'status', this.status);
}
}
const mixed = [
new Task({ id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván', priority: 'high', status: 'in-progress', estimatedHours: 12, dueDate: '2026-09-30' }),
new RecurringTask({ id: 8, title: 'Collect the screen-printing materials', assignee: 'Marta', priority: 'low', estimatedHours: 1, dueDate: '2026-09-25' }),
new BlockedTask({ id: 9, title: 'Install the new lathe', assignee: 'Lucía', priority: 'medium', estimatedHours: 6, dueDate: '2026-10-10', reason: "waiting for the owner's permission" })
];
for (const task of mixed) {
console.log(task.describe('2026-09-20')); // ← the SAME call
}
// ▸ [1] Redesign the multipurpose room · Iván · 12 h
// ○ [8] Collect the screen-printing materials · Marta · 1 h · repeats weekly
// ⛔ ○ [9] Install the new lathe · Lucía · 6 h · BLOCKED (waiting for the owner's permission)That loop does not contain a single if about the type. Compare it with the alternative you would have written in Module 2:
// ✗ Without polymorphism: every new class forces you to touch this if
for (const task of mixed) {
if (task.type === 'recurring') console.log(describeRecurring(task));
else if (task.type === 'blocked') console.log(describeBlocked(task));
else console.log(describeNormal(task));
}The polymorphic version is never touched when classes are added. That is the whole gain, and it is the main reason inheritance exists.
- The
Board class
Board classNow for the project's other big object: the board that in 04-02 was an object literal, turned into a class. It brings together the backlog and the operations from 04-05 in a single piece.
'use strict';
const TODAY = '2026-09-20';
class Board {
constructor(name, tasks = []) {
this.name = name;
this.tasks = [...tasks]; // a copy: nobody outside mutates our array (04-08)
}
// ── Queries ─────────────────────────────────────────────────────
findById(id) {
return this.tasks.find((t) => t.id === id) ?? null;
}
filter(predicate) {
return this.tasks.filter(predicate);
}
open() {
return this.tasks.filter((t) => t.isOpen());
}
hoursByAssignee() {
return this.open().reduce((acc, t) => {
const key = t.assignee ?? 'unassigned';
acc[key] = (acc[key] ?? 0) + t.estimatedHours;
return acc;
}, {});
}
// ── Modification ────────────────────────────────────────────────
add(task) {
if (!(task instanceof Task)) {
throw new ValidationError('Only instances of Task can be added.', 'task', task);
}
if (this.findById(task.id) !== null) {
throw new ValidationError(`A task with id ${task.id} already exists.`, 'id', task.id); // R1
}
const workload = this.hoursByAssignee()[task.assignee] ?? 0;
if (task.isOpen() && workload + task.estimatedHours > Task.MAX_HOURS) { // R7
throw new ValidationError(
`${task.assignee} would reach ${workload + task.estimatedHours} h, above the maximum of ${Task.MAX_HOURS}.`,
'estimatedHours', task.estimatedHours
);
}
this.tasks.push(task);
return this;
}
changeStatus(id, next) {
const task = this.findById(id);
if (task === null) throw new ValidationError(`There is no task with id ${id}.`, 'id', id);
task.changeStatus(next); // delegates R6 to the task itself (polymorphism included)
return this;
}
// ── Aggregation ─────────────────────────────────────────────────
summary(today = TODAY) {
const open = this.open();
return {
total: this.tasks.length,
open: open.length,
totalHours: this.tasks.reduce((s, t) => s + t.estimatedHours, 0),
openHours: open.reduce((s, t) => s + t.estimatedHours, 0),
overdue: open.filter((t) => t.isOverdue(today)).length,
effort: this.tasks.reduce((s, t) => s + t.effort(), 0)
};
}
paint(today = TODAY) {
return [`— ${this.name} —`, ...this.tasks.map((t) => t.describe(today))].join('\n');
}
}With the canonical backlog:
const backlogData = [
{ id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván', priority: 'high', status: 'in-progress', tags: ['space', 'design'], estimatedHours: 12, dueDate: '2026-09-30', reviewer: 'Marta' },
{ id: 2, title: 'Signage for the screen-printing workshop', assignee: 'Marta', priority: 'medium', status: 'pending', tags: ['screen-printing', 'communication'], estimatedHours: 6, dueDate: '2026-10-15', reviewer: null },
{ id: 3, title: 'Update the bookings website', assignee: 'Lucía', priority: 'high', status: 'pending', tags: ['web', 'bookings'], estimatedHours: 14, dueDate: '2026-10-02', reviewer: 'Iván' },
{ id: 4, title: 'Screen-printing ink inventory', assignee: 'Marta', priority: 'low', status: 'done', tags: ['screen-printing', 'storeroom'], estimatedHours: 3, dueDate: '2026-09-12', reviewer: null },
{ id: 5, title: 'Bookbinding guide for residents', assignee: 'Iván', priority: 'medium', status: 'in-progress', tags: ['bookbinding', 'documentation'], estimatedHours: 8, dueDate: '2026-11-05', reviewer: 'Lucía' },
{ id: 6, title: 'Carpentry workshop quote', assignee: 'Iván', priority: 'high', status: 'pending', tags: ['carpentry', 'purchasing'], estimatedHours: 5, dueDate: '2026-09-05', reviewer: 'Marta' }
];
const board = new Board('Taller Nómada', backlogData.map((d) => new Task(d)));
console.log(board.summary());
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }
console.log(board.hoursByAssignee());
// { 'Iván': 25, 'Marta': 6, 'Lucía': 14 }The project's five canonical numbers —48, 45, 1 overdue, effort 124, and the split Iván 25 / Lucía 14 / Marta 6— now come out of a single call to an object that knows how to answer for itself. And R7 really works:
try {
board.add(new Task({ id: 10, title: 'Set up the autumn exhibition', assignee: 'Iván',
priority: 'high', estimatedHours: 20, dueDate: '2026-10-30' }));
} catch (error) {
console.error(error.message);
// Iván would reach 45 h, above the maximum of 40.
}
- The four pillars of OOP
With Task, RecurringTask, BlockedTask and Board in front of you, the classic concepts of object-oriented programming stop being abstract.
| Pillar | What it means | Where it is in Nómada Tasks |
|---|---|---|
| Abstraction | Expose what something does and hide how | Whoever uses board.summary() does not know whether there is a reduce, a loop or a cache underneath |
| Encapsulation | The data and the operations that govern it travel together, and the state is only changed through controlled routes | changeStatus applies R6; add applies R1 and R7. It will be completed in 05-03 with #status |
| Inheritance | One class reuses and specializes the behavior of another | RecurringTask extends Task reuses validation, effort and overdue checks |
| Polymorphism | The same call produces each class's own behavior | The loop in section 7: a single task.describe(today) for three classes |
It is worth insisting on the difference between abstraction and encapsulation, because they get confused constantly. Abstraction is an interface design decision: which operations I offer. Encapsulation is a protection mechanism: what I stop from being touched. You can have a well-abstracted interface and zero encapsulation —which is exactly the current state of Task, where changeStatus exists but nothing stops task.status = 'done'—, and that is why the next lesson is devoted entirely to closing that gap.
- Composition versus inheritance
Inheritance is powerful, and that is why it gets overused. Imagine Marta asks for three more things: that a task can be exportable to CSV, that it can notify by email and that it can be archived. With inheritance you end up in an impossible tree: ExportableNotifiableRecurringTask. And if tomorrow a blocked task also has to be exportable, there is no way of expressing it, because a class can only have one parent.
The alternative is composition: instead of being a, having a.
'use strict';
// ── Independent pieces, reusable by any class ─────────────────────
class CsvExporter {
constructor(columns) {
this.columns = columns;
}
line(object) {
return this.columns.map((c) => String(object[c] ?? '')).join(';');
}
header() {
return this.columns.join(';');
}
}
class ChangeLog {
constructor() { this.entries = []; }
record(text, moment) { this.entries.push(`${moment} · ${text}`); }
last() { return this.entries.at(-1) ?? null; }
}
// ── The board USES those pieces, it does not inherit them ─────────
class AuditedBoard extends Board {
constructor(name, tasks) {
super(name, tasks);
this.log = new ChangeLog(); // composition
this.exporter = new CsvExporter(['id', 'title', 'assignee', 'status', 'estimatedHours']); // composition
}
changeStatus(id, next) {
super.changeStatus(id, next);
this.log.record(`Task ${id} → ${next}`, TODAY);
return this;
}
toCSV() {
return [this.exporter.header(), ...this.tasks.map((t) => this.exporter.line(t))].join('\n');
}
}
const audited = new AuditedBoard('Taller Nómada', board.tasks);
audited.changeStatus(2, 'in-progress');
console.log(audited.log.last()); // '2026-09-20 · Task 2 → in-progress'
console.log(audited.toCSV().split('\n')[1]); // '1;Redesign the multipurpose room;Iván;in-progress;12'CsvExporter and ChangeLog know nothing about tasks: they work just as well for a board, for a list of residents or for the room bookings. That is the difference:
Inheritance (extends) |
Composition (properties) | |
|---|---|---|
| Relationship | "is a" | "has a" |
| Coupling | Strong: the child depends on the parent's implementation | Weak: it only depends on a small interface |
| How many can be combined | A single parent class | As many pieces as you like |
| Changing at run time | Impossible | Trivial: you swap the piece |
| Typical risk | Deep, fragile hierarchies | A little more code to delegate |
The rule almost everyone follows:
Use inheritance when the child really is a special case of the parent and shares its complete contract. For everything else, compose.
RecurringTask is legitimate inheritance: it is a task in every sense, and wherever a Task fits, it will fit. CsvExporter is neither a task nor a board: it is a tool that gets used. And if you are in doubt, there is a practical test: if while inheriting you find yourself overriding the parent's methods so they do nothing or so they throw errors —as BlockedTask did with changeStatus—, that is a sign the "is a" relationship was not as clean as it looked.
class ValidationError extends Error, fully explained
class ValidationError extends Error, fully explainedYou have been using this recipe since 02-05 without understanding it. Now you have all the pieces.
'use strict';
class ValidationError extends Error {
constructor(message, field, receivedValue) {
super(message); // 1 · the Error constructor stores the message
this.name = 'ValidationError'; // 2 · overrides the inherited name ('Error')
this.field = field; // 3 · data of our own error
this.receivedValue = receivedValue;
}
describe() { // 4 · nothing stops you adding methods
return `[${this.name}] ${this.field}: ${this.message} (received: ${JSON.stringify(this.receivedValue)})`;
}
}Line by line, with what you have learned:
extends ErrorputsError.prototypein the chain, which is wheremessage,name,toStringand (in current engines)stackcome from. That is why the error prints properly in the console and produces a trace like the ones in 03-05.super(message)runs theErrorconstructor, which is what assignsthis.message. Without that call the error would exist but with an empty message —and besides, the language would not even let you get that far, because in a class withextendsyou must callsuperbefore touchingthis.this.name = 'ValidationError'shadows (05-01) thenameproperty inherited fromError.prototype, whose value is'Error'. That is what makes the console print the right name.- The extra fields (
field,receivedValue) are ordinary own properties: the advantage of having your own error class is precisely being able to carry structured information, instead of stuffing it into the message text.
And now the part that makes all of this worthwhile: instanceof lets you tell error types apart in the catch, something you did in 02-05 by comparing error.name with a string.
class DataError extends Error { // the one from 04-08
constructor(message, cause) {
super(message);
this.name = 'DataError';
this.cause = cause;
}
}
function process(text) {
try {
const task = Task.fromJSON(text);
task.changeStatus('done');
return task;
} catch (error) {
if (error instanceof ValidationError) {
console.error(error.describe());
return null; // recoverable: the user is warned
}
if (error instanceof DataError) {
console.error(`Corrupt data: ${error.message}`);
return null;
}
throw error; // unknown: let it bubble up (fail-fast from 02-05)
}
}
console.log(process('{"id":11,"title":"Varnish the shelves","estimatedHours":4,"dueDate":"2026-10-08"}'));
// ValidationError in… → null
// (pending → done is not a valid transition: R6)error instanceof ValidationError is more robust than error.name === 'ValidationError' because it checks the real prototype chain, not a piece of text anyone could write by accident. And since inheritance works, you can create families of errors:
class RuleError extends ValidationError {
constructor(message, field, receivedValue, rule) {
super(message, field, receivedValue);
this.name = 'RuleError';
this.rule = rule; // 'R6', 'R7'…
}
}
const e = new RuleError('Transition not allowed.', 'status', 'done', 'R6');
console.log(e instanceof RuleError); // true
console.log(e instanceof ValidationError); // true ← a generic catch also catches it
console.log(e instanceof Error); // trueA catch that checks instanceof ValidationError will also catch RuleErrors, without touching a line. That is polymorphism applied to error handling.
- Language details that classes change
Four behavioral differences worth being clear about.
Classes are not hoisted. Unlike the function declarations in 03-05, a class is in the temporal dead zone until its declaration:
Inside a class strict mode always applies, even if the file has no 'use strict'. It is one of those free fixes.
It cannot be called without new, not even by accident:
Methods are non-enumerable, so they no longer pollute for...in, which was the problem in section 11 of 05-01:
class Example {
field = 1;
method() {}
}
const e = new Example();
for (const k in e) console.log(k); // only 'field'
console.log(Object.keys(Example.prototype)); // [] ← the method is there, but it is not enumerableOne final warning that picks up 04-08: JSON.parse does not return instances. If you serialize a Board and read it back, you get plain objects with no methods.
const plainCopy = JSON.parse(JSON.stringify(board.tasks[0]));
console.log(plainCopy.title); // 'Redesign the multipurpose room' ✓ data
console.log(plainCopy instanceof Task); // false ✗ no behavior
// plainCopy.effort(); // ✗ TypeError: not a functionThe solution is the static factory from section 4: Task.fromJSON (or data.map((d) => new Task(d))) is the mandatory rehydration step when reading data from outside. The same goes for structuredClone, which copies the data but returns a plain object.
Common Mistakes and Tips
- Putting commas between class members. Inside
classthey are not separated with commas, unlike the object literals of Module 4.class A { m1() {}, m2() {} }is aSyntaxError. - Forgetting
super(...)in the child's constructor, or usingthisbefore calling it. TheReferenceErroris explicit, but the reason is baffling if you do not remember that it issuperthat initializes the parent's part. - Writing
functionin the methods.class A { function m() {} }is not valid: the syntax is the shorthand one from 04-02. - Defining a method called
constructorexpecting to overload. There is only one constructor; for several ways of creating, usestaticfactory methods. - Using an arrow function as a class method when you expect it to go on the prototype.
describe = () => {…}is an instance field, not a method: one copy is created per object (goodbye to the memory saving of 05-01) and it is not overridable withsuper. It has a legitimate use —it pinsthisso you can pass it as a callback, solving the problem from 04-02—, but it is a deliberate decision, not the normal way of writing methods. - Confusing
staticwith "shared by the instances". Astaticmember is not reachable from an instance:task.MAX_HOURSisundefined; you have to writeTask.MAX_HOURS(orthis.constructor.MAX_HOURSif you want subclasses to be able to redefine it). - Inheriting in order to reuse code. If you only want to take advantage of two functions, do not create an artificial parent: import those functions or compose. Inheritance expresses a conceptual relationship, not a shortcut.
- Deep hierarchies. More than two or three levels make it impossible to follow where each method comes from. When the depth grows, there was almost always a composition waiting.
- Tip: document your classes with JSDoc as you have been doing with functions in 03-03. A
@param {Object} dataover the constructor saves your editor —and you, three months from now— from having to read the whole body.
Exercises
Exercise 1 — From constructor to class. Translate the following legacy code to class syntax, fixing along the way the two flaws it contains (one about memory, one about shared data).
function Resident(name, plan) {
this.name = name;
this.plan = plan;
this.bookings = [];
this.describe = function () {
return `${this.name} · ${this.plan} plan · ${this.bookings.length} bookings`;
};
}
Resident.prototype.rates = { monthly: 120, daily: 12 };
Resident.prototype.fee = function () {
return this.rates[this.plan] ?? 0;
};Exercise 2 — TaskWithSubtasks. Write a class that extends Task and adds a subtasks array of Task instances. It must offer:
addSubtask(task), which validates that it is aTaskand adds it;totalHours(), which sums its hours and those of all its subtasks at any depth (recursion from 03-07);openHours(), the same but counting only those that are not done;- an overridden
describe(today)that appends(+N subtasks)when there are any.
Check it with the "Redesign the multipurpose room" tree: 12 h in total and 7 h open.
Exercise 3 — RuleError with a catalog. Write a class RuleError extends ValidationError that takes the rule code ('R6', 'R7'…) and exposes a static method RuleError.explain(code) returning the rule's text from a static catalog. Use it in Task.changeStatus and show that a catch (e) { if (e instanceof ValidationError) … } still catches it.
Solutions
Exercise 1
'use strict';
class Resident {
static RATES = { monthly: 120, daily: 12 }; // flaw 2 fixed: constant on the CLASS
constructor(name, plan) {
this.name = name;
this.plan = plan;
this.bookings = []; // a NEW array per instance
}
describe() { // flaw 1 fixed: method on the prototype
return `${this.name} · ${this.plan} plan · ${this.bookings.length} bookings`;
}
fee() {
return Resident.RATES[this.plan] ?? 0;
}
}
const lucia = new Resident('Lucía', 'monthly');
const ivan = new Resident('Iván', 'daily');
console.log(lucia.describe === ivan.describe); // true ← a single function
console.log(lucia.fee(), ivan.fee()); // 120 12The two flaws were the ones from section 9 of 05-01. The first, this.describe = function … inside the constructor, created one function per instance; in the class it becomes a method on the prototype. The second, Resident.prototype.rates = {…}, put a mutable object on the prototype: any resident.rates.monthly = 0 would have changed it for everyone. As a static field it is still shared on purpose, but it is accessed through the class, which makes the intent clear (and in 05-03 you will see how to make it read-only as well).
Exercise 2
'use strict';
class TaskWithSubtasks extends Task {
constructor(data) {
super(data);
this.subtasks = [];
for (const sub of data.subtasks ?? []) {
this.addSubtask(sub instanceof Task ? sub : new TaskWithSubtasks(sub));
}
}
addSubtask(task) {
if (!(task instanceof Task)) {
throw new ValidationError('A subtask must be a Task.', 'subtasks', task);
}
this.subtasks.push(task);
return this;
}
totalHours() {
return this.subtasks.reduce(
(sum, sub) => sum + (sub instanceof TaskWithSubtasks ? sub.totalHours() : sub.estimatedHours),
this.estimatedHours
);
}
openHours() {
const own = this.isOpen() ? this.estimatedHours : 0;
return this.subtasks.reduce(
(sum, sub) => sum + (sub instanceof TaskWithSubtasks
? sub.openHours()
: (sub.isOpen() ? sub.estimatedHours : 0)),
own
);
}
describe(today) {
const base = super.describe(today);
return this.subtasks.length > 0 ? `${base} (+${this.subtasks.length} subtasks)` : base;
}
}
const redesign = new TaskWithSubtasks({
id: 1, title: 'Redesign the multipurpose room', assignee: 'Iván',
priority: 'high', status: 'in-progress', estimatedHours: 0.0001, dueDate: '2026-09-30',
subtasks: [
{ id: 11, title: 'Measure and draw up the floor plan', assignee: 'Iván', status: 'done', estimatedHours: 3, dueDate: '2026-09-10' },
{ id: 12, title: 'Choose the furniture', assignee: 'Marta', status: 'in-progress', estimatedHours: 0.0001, dueDate: '2026-09-20',
subtasks: [
{ id: 121, title: 'Request quotes', assignee: 'Marta', status: 'done', estimatedHours: 2, dueDate: '2026-09-15' },
{ id: 122, title: 'Visit two suppliers', assignee: 'Marta', status: 'pending', estimatedHours: 2, dueDate: '2026-09-22' }
] },
{ id: 13, title: 'Paint and assemble', assignee: 'Iván', status: 'pending', estimatedHours: 5, dueDate: '2026-09-28' }
]
});
console.log(Math.round(redesign.totalHours())); // 12
console.log(Math.round(redesign.openHours())); // 7 (2 for visiting + 5 for painting)
console.log(redesign.describe('2026-09-20'));
// ▸ [1] Redesign the multipurpose room · Iván · 0.0001 h (+3 subtasks)Two honest observations about this solution. First, that estimatedHours: 0.0001 is an ugly workaround: the container nodes of the 03-07 tree had estimatedHours: 0, but the R3 we validate in the constructor demands they be greater than zero. It is a real clash between two rules, and the clean way out would be to allow 0 for container tasks —an explicit exception in the validation— rather than faking the data. Note it down for what it is: a pending design decision. Second, the recursion in totalHours is the same one from sumTotalHours in 03-07, now turned into a method; the base case is still implicit, because an empty subtask array makes reduce return the initial value directly.
Exercise 3
'use strict';
class RuleError extends ValidationError {
static CATALOG = {
R1: 'The id is unique and sequential; the application assigns it.',
R2: 'The title cannot be empty.',
R3: 'Estimated hours must be between 1 and 40.',
R6: 'Only the transitions pending → in-progress → done are allowed.',
R7: 'Nobody may go over 40 assigned hours in the same week.'
};
constructor(code, message, field, receivedValue) {
super(message, field, receivedValue);
this.name = 'RuleError';
this.rule = code;
}
static explain(code) {
return RuleError.CATALOG[code] ?? 'Unknown rule.';
}
describe() {
return `[${this.rule}] ${this.message} — ${RuleError.explain(this.rule)}`;
}
}
// In Task.changeStatus, replacing the throw from section 5:
// throw new RuleError('R6', `Transition not allowed: "${this.status}" → "${next}".`, 'status', next);
try {
new Task({ id: 12, title: 'Clean the room', estimatedHours: 2, dueDate: '2026-10-01' })
.changeStatus('done');
} catch (error) {
console.log(error instanceof RuleError); // true
console.log(error instanceof ValidationError); // true ← the generic catch catches it
console.log(error instanceof Error); // true
console.log(error.describe());
// [R6] Transition not allowed: "pending" → "done". — Only the transitions pending → in-progress → done are allowed.
}Notice two things. describe() is overridden and uses the static catalog, while the parent's version was still available through super.describe() had we wanted it. And the three instanceof results coming out true are the practical demonstration of why instanceof is used and not error.name: a handler written months ago for ValidationError keeps catching this new class without a single line being modified.
Conclusion
You have changed syntax without changing mechanism. class does not introduce a new object system: underneath there is still a constructor function and a prototype object, exactly the ones from 05-01, as typeof Task being 'function' and Object.getPrototypeOf(t) === Task.prototype prove. What it brings is readability —constructor and methods together in one block— and a handful of fixes you no longer have to remember: non-enumerable methods, automatic strict mode, a correctly pointed constructor, the impossibility of forgetting the new, and extends instead of the three lines of ritual.
You now have the complete vocabulary. The constructor takes the arguments and fills in the object; instance methods live on the prototype and are shared; fields are own properties with an initial value, ideal for defaults like status = 'pending'; and static members belong to the class, serving for constants (Task.STATUSES, Task.TRANSITIONS, Task.MAX_HOURS), counters (Task.lastId) and above all factories like Task.fromJSON, which make up for the lack of constructor overloading and are the mandatory step for rehydrating the plain data JSON.parse returns. With extends and super —super(data) in the constructor, super.method() in the methods— you have built RecurringTask and BlockedTask, and you have seen polymorphism at work: a loop that calls task.describe(today) on three different classes and contains not a single if about the type.
The project's model is now in place. class Task validates in the constructor (R2, R3, R8, R9), computes effort and overdue status (R10) and protects the status transitions with changeStatus (R6). class Board holds the backlog, checks for duplicate ids (R1) and the maximum workload per person (R7), and produces the canonical summary in one go: 48 h in total, 45 open, 1 overdue, effort 124, with Iván on 25 h, Lucía on 14 and Marta on 6. The four pillars of OOP have stopped being a list of words and now point at concrete lines of that code, you know when inheritance is legitimate ("is a") and when it is better to compose ("has a"), with CsvExporter and ChangeLog as reusable pieces. And class ValidationError extends Error is no longer a copied recipe: you understand what super(message) does, why name is overridden, where message and stack come from, and why error instanceof ValidationError is better than comparing strings.
There is, however, one promise still unkept. You have written changeStatus so that nobody can skip R6… and any line of the program can still write task.status = 'done' and leave the object in an impossible state. The same goes for task.estimatedHours = 500, which walks straight through the constructor's validation as if it did not exist, or for Task.lastId = 0, which would break R1 forever. The abstraction is well designed, but encapsulation is still a convention resting on good will. Closing that gap —with get, set, private #status fields and read-only properties— is the subject of Encapsulation: Getters, Setters and Private Fields.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
