The previous lesson ended with a promise unkept. You wrote changeStatus so that transitions would respect rule R6, and yet any line of the program can still write task.status = 'done' and leave the object in an impossible state. The method exists, but it is not mandatory: it is a polite suggestion. This lesson is about turning that suggestion into a real barrier. You will learn to expose computed properties with get, to intercept writes with set, to genuinely hide the state with private fields #field —a language feature, not a convention—, to declare read-only properties with Object.defineProperty, and to design a minimal public API for Board. And since privacy interferes with the serialization you learned in 04-08, you will close with toJSON so that exporting the backlog keeps working.

Contents

  1. What a consumer can break without encapsulation
  2. get: computed properties
  3. set: validating on assignment
  4. When a getter and when a method
  5. Private fields #field
  6. Private methods and private statics
  7. The #field in object operator
  8. The historical alternatives: _field, closures and WeakMap
  9. Read-only properties and Object.defineProperty
  10. Designing the public API of Board
  11. Privacy and JSON.stringify: toJSON to the rescue
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. What a consumer can break without encapsulation

Let us start by measuring the damage. This is the Task from 05-02, with all its validation, and these are four lines that destroy it without the object complaining once.

'use strict';

const task = new Task({
  id: 6, title: 'Carpentry workshop quote', assignee: 'Iván',
  priority: 'high', estimatedHours: 5, dueDate: '2026-09-05'
});

// ✗ 1 · Skips R6: from 'pending' to 'done' without going through 'in-progress'
task.status = 'done';

// ✗ 2 · Skips R3: hours outside the 1-40 range
task.estimatedHours = 500;

// ✗ 3 · Non-existent status: it is not even in Task.STATUSES
task.status = 'archived';

// ✗ 4 · Skips R8: empty string instead of null
task.assignee = '';

console.log(task.describe('2026-09-20'));
// ? [6] Carpentry workshop quote ·  · 500 h

That ? where the badge should be and that gap where the assignee should be are the visible consequences. The invisible ones are worse: the board summary now says there are 543 open hours, Marta's report does not add up, and when somebody debugs the problem three weeks from now there will be no way of knowing which line made the illegal assignment, because an assignment leaves no trace in any stack.

The technical diagnosis is simple: status, estimatedHours and assignee are ordinary public properties, and in JavaScript a public property is an open contract. Encapsulation means reducing the contact surface: leaving visible only what the consumer needs, and forcing every change to go through a method that knows how to validate it.

flowchart LR
    subgraph Without["Without encapsulation"]
        C1["External code"] -->|"task.status = 'done'"| E1["status<br/>(public)"]
        C1 -->|"task.changeStatus('done')"| M1["changeStatus()"]
        M1 --> E1
    end
    subgraph With["With encapsulation"]
        C2["External code"] -.->|"✗ unreachable"| E2["#status<br/>(private)"]
        C2 -->|"task.changeStatus('done')"| M2["changeStatus()<br/>validates R6"]
        M2 --> E2
    end

  1. get: computed properties

The first step is gentler than private fields and already solves plenty of cases: a property that is not stored, but computed.

'use strict';

class Task {
  constructor(data) {
    this.id = data.id;
    this.title = data.title;
    this.status = data.status ?? 'pending';
    this.estimatedHours = data.estimatedHours;
    this.dueDate = data.dueDate;
  }

  get isOpen() {
    return this.status !== 'done';
  }

  get daysLeft() {
    const due = new Date(this.dueDate);
    const today = new Date('2026-09-20');
    return Math.round((due - today) / (1000 * 60 * 60 * 24));
  }
}

const carpentryQuote = new Task({ id: 6, title: 'Carpentry workshop quote', estimatedHours: 5, dueDate: '2026-09-05' });

console.log(carpentryQuote.isOpen);      // true    ← no parentheses
console.log(carpentryQuote.daysLeft);    // -15     ← 15 days overdue

The two features that define a getter:

  • It is read like a property, with no parentheses. carpentryQuote.isOpen, not carpentryQuote.isOpen().
  • It runs every time it is read, so the value is always up to date. If you change status, isOpen changes by itself: there is no duplicated piece of data to keep in sync.

That second point is the strong argument. The alternative —storing this.isOpen = true in the constructor— creates two sources of truth that sooner or later drift apart.

// ✗ Derived data stored: you have to remember to update it ALWAYS
this.isOpen = data.status !== 'done';
task.status = 'done';
console.log(task.isOpen);          // true  ← a lie

A getter also works in object literals, not just in classes, with the same syntax:

const team = {
  members: ['Marta', 'Iván', 'Lucía'],
  get size() { return this.members.length; }
};
console.log(team.size);            // 3

And on the Board getters shine especially, because almost everything you look up is derived:

class Board {
  constructor(name, tasks = []) {
    this.name = name;
    this.tasks = [...tasks];
  }

  get total()       { return this.tasks.length; }
  get open()        { return this.tasks.filter((t) => t.isOpen); }
  get totalHours()  { return this.tasks.reduce((s, t) => s + t.estimatedHours, 0); }
  get openHours()   { return this.open.reduce((s, t) => s + t.estimatedHours, 0); }
  get effort()      { return this.tasks.reduce((s, t) => s + t.effort(), 0); }
}

console.log(board.total);         // 6
console.log(board.totalHours);    // 48
console.log(board.openHours);     // 45
console.log(board.effort);        // 124

Look at openHours: it uses the open getter, which in turn walks tasks. Getters can be chained together perfectly naturally, and the result reads like a description of the data, not like a sequence of calls.

  1. set: validating on assignment

A setter is the other half: a function that runs when somebody assigns to the property. It takes exactly one parameter, the assigned value.

'use strict';

class Task {
  constructor(data) {
    this.id = data.id;
    this._hours = 0;
    this.estimatedHours = data.estimatedHours;   // ← goes through the setter, gets validated
  }

  get estimatedHours() {
    return this._hours;
  }

  set estimatedHours(value) {
    if (typeof value !== 'number' || Number.isNaN(value)) {
      throw new ValidationError('Hours must be a number.', 'estimatedHours', value);
    }
    if (value <= 0 || value > 40) {                                              // R3
      throw new ValidationError('Hours must be between 1 and 40.', 'estimatedHours', value);
    }
    this._hours = value;
  }
}

const t = new Task({ id: 6, estimatedHours: 5 });
console.log(t.estimatedHours);      // 5

t.estimatedHours = 8;               // ✓ valid
console.log(t.estimatedHours);      // 8

try {
  t.estimatedHours = 500;           // ✗ now it DOES throw
} catch (error) {
  console.error(error.message);     // 'Hours must be between 1 and 40.'
}
console.log(t.estimatedHours);      // 8   ← the object was left untouched

There is no longer any way of putting 500 hours into a task, not even by direct assignment. Four details to be clear about:

  • The getter and the setter share a name, and that name cannot be the field where the data is stored. If you wrote set estimatedHours(v) { this.estimatedHours = v; } you would cause infinite recursion: the assignment calls the setter again. That is why the internal store has a different name (_hours here, and #hours as soon as we reach section 5).
  • A setter with no getter makes reading the property return undefined. They are almost always declared in pairs.
  • A getter with no setter turns the property into read-only: in strict mode, assigning to it throws a TypeError. It is a perfectly legitimate use.
  • Validating in the constructor is not enough. Notice that the constructor above assigns this.estimatedHours = data.estimatedHours instead of touching _hours directly: that way the setter's validation applies at creation too, and the rule lives written in one place only.
class ReadOnly {
  #value = 42;
  get value() { return this.#value; }
}

const ro = new ReadOnly();
// ro.value = 7;      // ✗ TypeError: Cannot set property value… which has only a getter

  1. When a getter and when a method

Getters and methods do similar things, and choosing wrongly produces awkward APIs. The practical rule:

Use a getter when… Use a method when…
It returns a piece of data conceptually belonging to the object It performs an action or a calculation with a verb-like name
It is cheap (walking a short array, a subtraction) It is expensive (walking thousands of elements, sorting, cloning)
It takes no arguments It takes arguments
It has no side effects and does not throw under normal conditions It may modify the object or fail
Reading it twice in a row gives the same result The result may change between calls

Applied to the project:

class Task {
  get isOpen()       { … }          // ✓ own data, cheap, no arguments
  get daysLeft()     { … }          // ✓ derived from dueDate

  isOverdue(today)   { … }          // ✓ method: takes an argument
  changeStatus(next) { … }          // ✓ method: it is an ACTION, and it can throw
  effort()           { … }          // ✓ method: could be a getter; kept as a method for consistency
}

The cost criterion deserves a warning, because it is the one that surprises people most. Whoever reads board.openHours assumes it is a cheap read, and does not think twice before putting it inside a loop:

// If openHours were expensive, this would be an invisible disaster
for (const task of board.tasks) {
  console.log(task.estimatedHours / board.openHours);   // ← recomputed on every pass
}

With six tasks it makes no difference. With five thousand, that loop becomes quadratic and nobody would suspect a simple property read. The rule is honest: if the calculation is expensive, give it the shape of a method (calculateOpenHours()), because the parentheses warn that something is happening there. And if you need an expensive getter, cache the result internally and invalidate it when the state changes —a technique that is the memoization from 03-06 applied to objects, and which is studied calmly in Module 9.

  1. Private fields #field

Getters and setters control access, but in section 3 the real store was still _hours: a public property with a name that asks you not to touch it. Since ES2022 the language offers genuine privacy.

'use strict';

class Task {
  #status = 'pending';            // private field, with an initial value (R5)

  constructor(data) {
    this.id = data.id;
    this.title = data.title;
    if (data.status !== undefined) this.#validateAndSetStatus(data.status, true);
  }

  get status() {
    return this.#status;          // public read, writing impossible
  }

  changeStatus(next) {
    this.#validateAndSetStatus(next, false);
    return this;
  }

  #validateAndSetStatus(next, isInitial) { /* section 6 */ }
}

const t = new Task({ id: 6, title: 'Carpentry workshop quote' });

console.log(t.status);            // 'pending'   ← read through the getter
// t.#status;                     // ✗ SyntaxError: Private field '#status' must be declared in an enclosing class
// t.status = 'done';             // ✗ TypeError: it only has a getter

Private fields have some very specific properties worth listing:

  • The hash is part of the name. #status and status are different properties that can coexist, which is exactly what we do: #status stores, status (the getter) exposes.
  • They must be declared in the class body. You cannot create one on the fly with this.#new = 1 if it is not declared: it is a SyntaxError, caught before running.
  • They are only reachable from inside the class that declares them. Not even from a subclass: if RecurringTask extends Task needs the status, it has to use the public getter.
  • The error is syntactic, not runtime. Accessing from outside does not even compile, which means the failure shows up immediately and not in production.
  • They do not appear in Object.keys, for...in, JSON.stringify, Object.entries or the spread {...obj}. That last one has consequences we will resolve in section 11.

This is real privacy, and it is what was missing to keep the promise from 05-02:

const task = new Task({ id: 6, title: 'Carpentry workshop quote' });

task.status = 'done';                       // in strict mode → TypeError
console.log(task.status);                   // 'pending'  ← untouched

task.changeStatus('in-progress');           // ✓ the only route
console.log(task.status);                   // 'in-progress'

  1. Private methods and private statics

The hash also works on methods, static fields and static methods. It serves the same purpose: separating the internal mechanism from the interface.

This is the complete version of the project's encapsulated Task, the lesson's central example:

'use strict';

class Task {
  // ── Private class configuration ─────────────────────────────────
  static #TRANSITIONS = {                        // R6
    pending: ['in-progress'],
    'in-progress': ['pending', 'done'],
    done: []
  };
  static #BADGES = { pending: '○', 'in-progress': '▸', done: '✓' };
  static #WEIGHTS = { high: 3, medium: 2, low: 1 };
  static #lastId = 6;                            // R1: the backlog goes up to 6

  // ── Private instance state ──────────────────────────────────────
  #status = 'pending';                           // R5
  #hours;
  #tags = [];

  constructor(data) {
    this.id = data.id ?? Task.nextId();
    this.title = Task.#validateTitle(data.title);
    this.assignee = data.assignee || null;                        // R8
    this.priority = data.priority ?? 'medium';
    this.dueDate = data.dueDate;
    this.reviewer = data.reviewer ?? null;

    this.estimatedHours = data.estimatedHours;                    // goes through the setter (R3)
    this.tags = data.tags ?? [];                                  // goes through the setter (R9)
    if (data.status !== undefined) this.#setInitialStatus(data.status);
  }

  // ── Statics ─────────────────────────────────────────────────────
  static nextId() {
    Task.#lastId += 1;
    return Task.#lastId;
  }

  static #validateTitle(title) {                                  // PRIVATE static method
    if (typeof title !== 'string' || title.trim() === '') {
      throw new ValidationError('The title cannot be empty.', 'title', title);   // R2
    }
    return title.trim();
  }

  // ── Getters ─────────────────────────────────────────────────────
  get status()         { return this.#status; }
  get estimatedHours() { return this.#hours; }
  get tags()           { return [...this.#tags]; }                // defensive copy
  get isOpen()         { return this.#status !== 'done'; }
  get effort()         { return (Task.#WEIGHTS[this.priority] ?? 0) * this.#hours; }
  get badge()          { return Task.#BADGES[this.#status]; }

  // ── Setters ─────────────────────────────────────────────────────
  set estimatedHours(value) {
    if (typeof value !== 'number' || !(value > 0 && value <= 40)) {
      throw new ValidationError('Hours must be a number between 1 and 40.', 'estimatedHours', value);
    }
    this.#hours = value;
  }

  set tags(list) {
    if (!Array.isArray(list)) {
      throw new ValidationError('Tags must be an array.', 'tags', list);
    }
    this.#tags = [...new Set(list.map((tag) => String(tag).trim().toLowerCase()))].filter(Boolean);   // R9
  }

  // ── Private methods ─────────────────────────────────────────────
  #setInitialStatus(status) {
    if (!Object.hasOwn(Task.#TRANSITIONS, status)) {
      throw new ValidationError(`Unknown status: "${status}".`, 'status', status);
    }
    this.#status = status;
  }

  #canMoveTo(next) {
    return (Task.#TRANSITIONS[this.#status] ?? []).includes(next);
  }

  // ── Public interface ────────────────────────────────────────────
  changeStatus(next) {
    if (!Object.hasOwn(Task.#TRANSITIONS, next)) {
      throw new ValidationError(`Unknown status: "${next}".`, 'status', next);
    }
    if (!this.#canMoveTo(next)) {
      throw new ValidationError(
        `Transition not allowed: "${this.#status}" → "${next}".`, 'status', next);   // R6
    }
    this.#status = next;
    return this;
  }

  isOverdue(today) {
    return this.dueDate < today && this.isOpen;                   // R10
  }

  describe(today) {
    const warning = today !== undefined && this.isOverdue(today) ? ' ⚠ OVERDUE' : '';
    return `${this.badge} [${this.id}] ${this.title} · ${this.assignee ?? 'unassigned'} · ${this.#hours} h${warning}`;
  }
}

And the proof that the object can no longer be corrupted:

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.title);                    // 'Carpentry workshop quote'  ← trimmed, R2
console.log(carpentryQuote.tags);                     // [ 'carpentry', 'purchasing' ]  ← R9
console.log(carpentryQuote.describe('2026-09-20'));   // ○ [6] Carpentry workshop quote · Iván · 5 h ⚠ OVERDUE
console.log(carpentryQuote.effort);                   // 15

// The four attacks from section 1, now:
try { carpentryQuote.status = 'done'; }            catch (e) { console.log('✓ blocked:', e.constructor.name); }
try { carpentryQuote.estimatedHours = 500; }       catch (e) { console.log('✓ blocked:', e.message); }
try { carpentryQuote.changeStatus('archived'); }   catch (e) { console.log('✓ blocked:', e.message); }
try { carpentryQuote.changeStatus('done'); }       catch (e) { console.log('✓ blocked:', e.message); }

console.log(carpentryQuote.status, carpentryQuote.estimatedHours);   // 'pending' 5   ← untouched

Two design decisions in that code are worth pointing out:

The defensive copy in the tags getter. Returning [...this.#tags] instead of the internal array stops anyone doing task.tags.push('URGENT') and skipping R9 by mutating from outside. It is exactly the shallow-copy danger you studied in 04-08, used here as a defense. The price is that it creates a new array on every read, so it is only justified when the array is small and the risk is real.

The setters are used from the constructor too. this.estimatedHours = data.estimatedHours inside the constructor invokes the setter and applies R3. If the constructor wrote this.#hours = data.estimatedHours, the validation would exist only for later modifications, and the rule would end up duplicated. One rule, one place.

  1. The #field in object operator

Since accessing a private field from outside is a SyntaxError, you cannot check whether it exists with a try/catch. That is what a special form of the in operator is for, known as a brand check:

'use strict';

class Task {
  #status = 'pending';

  /** Is this object a genuine Task, created by this class? */
  static isTask(object) {
    return #status in object;
  }
}

const real = new Task({ id: 1, title: 'Redesign the multipurpose room', estimatedHours: 12, dueDate: '2026-09-30' });
const impostor = { id: 1, title: 'Redesign the multipurpose room', status: 'pending' };

console.log(Task.isTask(real));        // true
console.log(Task.isTask(impostor));    // false
console.log(Task.isTask(null));        // false  (for objects, in returns false, it does not throw)

The expression #status in object can only be written inside the class that declares #status, and it returns true or false without ever throwing. Why not use instanceof, which you already know from 05-01? Because instanceof looks at the prototype chain, and that can be faked:

const forged = Object.create(Task.prototype);
console.log(forged instanceof Task);       // true   ← it lies!
console.log(Task.isTask(forged));          // false  ← the brand cannot be faked
// forged.describe();                      // ✗ TypeError: it has no private fields

That object has the methods but none of the private fields, because it never went through the constructor. An instanceof would accept it and the program would blow up later, somewhere confusing. The brand check is the honest test: only objects that really went through this class's constructor have its private fields. It is the right technique when a method needs to guarantee it can touch its argument's internal state.

  1. The historical alternatives: _field, closures and WeakMap

# fields arrived in 2022. Before that, three techniques were used, and you will find them in any code a few years old, so it is worth recognizing them.

The underscore convention. A _ in front of the name means "this is internal, do not touch it". There is no mechanism behind it: it is an agreement between people.

class LegacyTask {
  constructor(status) { this._status = status; }
  get status() { return this._status; }
}
const t = new LegacyTask('pending');
t._status = 'archived';         // works perfectly: nothing prevents it

Closures (the module pattern from 03-04). The state lives in variables captured by the returned functions. It is real privacy, the same as in createTaskStore.

function createTask(initialData) {
  let status = 'pending';                       // ← genuinely unreachable from outside
  const transitions = { pending: ['in-progress'], 'in-progress': ['pending', 'done'], done: [] };

  return {
    id: initialData.id,
    title: initialData.title,
    get status() { return status; },
    changeStatus(next) {
      if (!transitions[status].includes(next)) throw new ValidationError('Transition not allowed.', 'status', next);
      status = next;
      return this;
    }
  };
}

It works, but it has the flaw that motivated the whole module: the methods are created afresh on every call, because they live on the returned object and not on a prototype. It is the problem from section 1 of 05-01.

WeakMap with the instance as the key. The trick serious libraries used before ES2022: an external table associating each instance with its private data.

const privateData = new WeakMap();

class WeakTask {
  constructor(data) {
    privateData.set(this, { status: 'pending' });
    this.id = data.id;
  }
  get status() { return privateData.get(this).status; }
  changeStatus(next) { privateData.get(this).status = next; return this; }
}

It is real privacy and it is compatible with prototypes, but verbose. The point of the Weak: it holds weak references, so when the instance stops being used the garbage collector can take its entry away too. A normal Map here would cause a memory leak, a topic for Module 9.

The full comparison:

Technique Real privacy? Methods on the prototype Verbosity Where you will see it
_field (convention) No Yes Minimal Code from before 2022, and in teams that prefer not to lock tests out
Closure (module pattern) Yes No Medium Factory functions, configuration modules
External WeakMap Yes Yes High Libraries from before ES2022
#field Yes Yes Minimal The default choice today

With one honest caveat about #: since it is real privacy, your tests cannot look inside either (Module 8). That forces you to test the object through its public interface, which is the right thing to do, but it demands that the interface be well designed. Some teams still prefer _ for that reason; it is a defensible team decision, not a mistake.

  1. Read-only properties and Object.defineProperty

There is still an open hole in the model: the id. It is an ordinary public property, and R1 says the application assigns it and nobody changes it.

carpentryQuote.id = 999;      // ✗ nothing stops this yet

A getter with no setter over a private field would already solve it, but there is a more precise, general-purpose tool: Object.defineProperty, which lets you create a property by specifying its attributes rather than just its value.

'use strict';

class Task {
  constructor(data) {
    Object.defineProperty(this, 'id', {
      value: data.id,
      writable: false,       // cannot be reassigned
      enumerable: true,      // does show up in Object.keys and JSON.stringify
      configurable: false    // cannot be deleted or redefined
    });
    this.title = data.title;
  }
}

const t = new Task({ id: 6, title: 'Carpentry workshop quote' });

console.log(t.id);                      // 6
// t.id = 999;                          // ✗ TypeError: Cannot assign to read only property 'id'
// delete t.id;                         // ✗ TypeError: Cannot delete property 'id'
console.log(Object.keys(t));            // [ 'id', 'title' ]
console.log(JSON.stringify(t));         // {"id":6,"title":"Carpentry workshop quote"}

The three attributes, with their practical effect:

Attribute If true If false Default with defineProperty
writable Can be reassigned with = Assigning throws TypeError in strict mode (and is ignored without it) false
enumerable Shows up in Object.keys, for...in, spread, JSON.stringify Exists but stays invisible to those operations false
configurable Can be deleted with delete or redefined delete and redefining throw TypeError false

Classic trap: a property created by normal assignment (this.id = 6) has all three attributes set to true; one created with Object.defineProperty has them all false unless you say otherwise. If you define a property and it then "disappears" from JSON.stringify, you forgot enumerable: true.

You can inspect the attributes of any property:

console.log(Object.getOwnPropertyDescriptor(t, 'id'));
// { value: 6, writable: false, enumerable: true, configurable: false }

console.log(Object.getOwnPropertyDescriptor(t, 'title'));
// { value: '…', writable: true, enumerable: true, configurable: true }

And this finally explains two things left hanging in earlier lessons. First, why the methods of Object.prototype did not show up in for...in in 05-01: they are non-enumerable. Second, how to make a method invisible to enumeration without using class:

Object.defineProperty(Task.prototype, 'hidden', { value() { return 1; }, enumerable: false });

A comparison with Object.freeze, which you met in 04-08:

Tool Scope Typical use
writable: false on a property One property A task's id (R1)
Object.freeze(obj) All the object's properties, shallowly The constants WEIGHTS, BADGES
Private #field Invisible from outside, writable from inside The #status, which does change through methods

The three combine according to what you want: invisible (private), immutable (writable: false) or both.

  1. Designing the public API of Board

With all the pieces in place, the most valuable design exercise: deciding what is visible and what is not in the project's central piece. The question that guides the decision is always the same: what does whoever uses this object really need?

In the Board class from 05-02, this.tasks was public. That means anybody can do board.tasks.push(anythingAtAll), skipping the validation in add, R1 on unique ids and R7 on maximum workload. The task list is precisely the invariant the board must protect.

'use strict';

class Board {
  #tasks = [];
  #name;

  constructor(name, tasks = []) {
    this.#name = name;
    for (const task of tasks) this.add(task);         // every entry goes through the official door
  }

  // ── Reads ───────────────────────────────────────────────────────
  get name()       { return this.#name; }
  get total()      { return this.#tasks.length; }
  get tasks()      { return [...this.#tasks]; }                 // a copy: mutating it does not affect the board
  get open()       { return this.#tasks.filter((t) => t.isOpen); }
  get totalHours() { return this.#tasks.reduce((s, t) => s + t.estimatedHours, 0); }
  get openHours()  { return this.open.reduce((s, t) => s + t.estimatedHours, 0); }
  get effort()     { return this.#tasks.reduce((s, t) => s + t.effort, 0); }

  findById(id)      { return this.#tasks.find((t) => t.id === id) ?? null; }
  filter(predicate) { return this.#tasks.filter(predicate); }

  overdue(today)    { return this.open.filter((t) => t.isOverdue(today)); }

  hoursByAssignee() {
    return this.open.reduce((acc, t) => {
      const key = t.assignee ?? 'unassigned';
      acc[key] = (acc[key] ?? 0) + t.estimatedHours;
      return acc;
    }, {});
  }

  summary(today) {
    return {
      total: this.total, open: this.open.length,
      totalHours: this.totalHours, openHours: this.openHours,
      overdue: this.overdue(today).length, effort: this.effort
    };
  }

  // ── Writes (the only doors) ─────────────────────────────────────
  add(task) {
    if (!(task instanceof Task)) throw new ValidationError('Only instances of Task are accepted.', 'task', task);
    if (this.findById(task.id)) throw new ValidationError(`Duplicate id: ${task.id}.`, 'id', task.id);   // R1
    this.#checkWorkload(task);                                                                          // R7
    this.#tasks.push(task);
    return this;
  }

  remove(id) {
    const i = this.#tasks.findIndex((t) => t.id === id);
    if (i === -1) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
    return this.#tasks.splice(i, 1)[0];
  }

  changeStatus(id, next) {
    const task = this.findById(id);
    if (task === null) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
    task.changeStatus(next);
    return this;
  }

  // ── Internal mechanism ──────────────────────────────────────────
  #checkWorkload(task) {
    if (!task.isOpen || task.assignee === null) return;
    const current = this.hoursByAssignee()[task.assignee] ?? 0;
    if (current + task.estimatedHours > 40) {
      throw new ValidationError(
        `${task.assignee} would reach ${current + task.estimatedHours} h (maximum 40).`, 'estimatedHours', task.estimatedHours);
    }
  }
}

In use, with the canonical backlog:

const board = new Board('Taller Nómada', backlogData.map((d) => new Task(d)));

console.log(board.summary('2026-09-20'));
// { 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 attempts to go around the API:
board.tasks.push('this is not a task');
console.log(board.total);                       // 6   ← a copy was mutated, the board never noticed

try { board.add('this is not one either'); }
catch (e) { console.log('✓ blocked:', e.message); }   // Only instances of Task are accepted.

The design criterion, summed up in a table:

Exposed Not exposed Reason
name, total, summary() The real #tasks array It is the invariant that has to be protected
tasks as a copy Lets you iterate without letting you mutate
add, remove, changeStatus #checkWorkload It is mechanism, not interface
filter(predicate) Gives total flexibility without opening up the array

Two honest nuances. The tasks getter copies the array, but the tasks inside are the same instances: it is a shallow copy (04-08), so whoever receives it can indeed call task.changeStatus(...). That is intentional —the task already protects its own state— but it is worth knowing. And filter(predicate) is a deliberately open door: instead of writing twenty query methods, the consumer is allowed to bring their own predicate, which is exactly the higher-order function from 03-06.

  1. Privacy and JSON.stringify: toJSON to the rescue

There is a price to pay for all this protection, and it appears the moment you try to save the data.

const task = new Task({
  id: 6, title: 'Carpentry workshop quote', assignee: 'Iván',
  priority: 'high', tags: ['carpentry', 'purchasing'],
  estimatedHours: 5, dueDate: '2026-09-05', reviewer: 'Marta'
});

console.log(JSON.stringify(task));
// {"id":6,"title":"Carpentry workshop quote","assignee":"Iván","priority":"high","dueDate":"2026-09-05","reviewer":"Marta"}

status, estimatedHours and tags are missing. You have known the reason since 04-08: JSON.stringify walks the own enumerable properties, and private fields are not among them. Getters do not count either: they live on the prototype, and stringify does not look at the chain. The result is a silently incomplete JSON, which is the worst kind of failure possible: it throws no error at all and is only discovered when the data is imported.

The solution is the toJSON method you met in 04-08: if an object has one, JSON.stringify uses its return value instead of the object.

class Task {
  // …everything above…

  /** Serializable representation: includes the private state as public fields. */
  toJSON() {
    return {
      id: this.id,
      title: this.title,
      assignee: this.assignee,
      priority: this.priority,
      status: this.#status,
      tags: [...this.#tags],
      estimatedHours: this.#hours,
      dueDate: this.dueDate,
      reviewer: this.reviewer
    };
  }

  /** The way back: rebuilds an instance from plain data. */
  static fromJSON(data) {
    return new Task(typeof data === 'string' ? JSON.parse(data) : data);
  }
}

console.log(JSON.stringify(task));
// {"id":6,…,"status":"pending","tags":["carpentry","purchasing"],"estimatedHours":5,…}

And on the board, the complete export/import cycle from 04-08, now with classes:

class Board {
  // …everything above…

  toJSON() {
    return { version: 1, name: this.#name, tasks: this.#tasks };   // stringify calls each task's toJSON
  }

  static importFrom(text) {
    let data;
    try {
      data = JSON.parse(text);
    } catch (error) {
      throw new DataError('The backlog is not valid JSON.', error);
    }
    if (data.version !== 1) throw new DataError(`Unsupported version: ${data.version}.`);
    return new Board(data.name, data.tasks.map((d) => Task.fromJSON(d)));
  }
}

const text = JSON.stringify(board);
const restored = Board.importFrom(text);

console.log(restored.summary('2026-09-20'));
// { total: 6, open: 5, totalHours: 48, openHours: 45, overdue: 1, effort: 124 }
console.log(restored.findById(6) instanceof Task);   // true  ← real instances

The toJSON / fromJSON pair is the pattern to remember: one explicit way out and one explicit way in, controlled by the class, which also revalidate on import. Without them, everything private is lost when you save.

One final warning about structuredClone, which you studied in 04-08: it does not work with private fields. It throws a DataCloneError because it cannot rebuild the class. Deep-copying an instance is done with Task.fromJSON(task.toJSON()), or with a clone() method that does exactly that.

Common Mistakes and Tips

  • Infinite recursion in the setter. set status(v) { this.status = v; } calls itself until the stack runs out (the RangeError from 03-05). The internal store must have another name: #status.
  • Validating only in the setter and skipping it in the constructor. If the constructor writes this.#hours = data.hours instead of this.estimatedHours = data.hours, the validation does not apply at creation. Always assign through the public property.
  • Using #field from a subclass. Private fields are not inherited: RecurringTask cannot read this.#status. If a subclass needs access, expose a getter (or protected by convention with _, which JavaScript does not have as a mechanism).
  • Forgetting enumerable: true in Object.defineProperty. The default is false, and the property disappears from Object.keys and JSON.stringify without warning.
  • Expensive getters. A getter that sorts or walks thousands of elements misleads whoever reads it. If it costs, make it a method with parentheses.
  • Getters with side effects. A getter that modifies the object, increments a counter or throws errors under normal conditions breaks the expectation that reading a property is harmless, and makes debugging very hard (the browser console itself runs getters when you inspect an object).
  • Returning the internal array from a getter. get tags() { return this.#tags; } leaves the door open to task.tags.push(…). Return a copy if the array has to keep invariants.
  • Encapsulating for the sake of it. A pure data object —a row read from an API, a {x, y} point— gains nothing from fifteen trivial getters. Encapsulation is for objects that have rules to protect.
  • Tip: to decide what to make private, first write how you want the object to be used from outside. Everything that does not appear in those usage lines is a candidate for being private.

Exercises

Exercise 1 — Resident with an hours quota. Write a Resident class for the Taller Nómada coworking space with:

  • private fields #bookedHours (starting at 0) and #plan;
  • a read-only name via Object.defineProperty (R1 applied to people);
  • getters plan, bookedHours, hoursAvailable and hasHoursLeft (true if there are hours remaining);
  • a plan setter that accepts only 'monthly' (40 h) or 'daily' (8 h) and that rejects a plan change if the hours already booked would exceed the new plan's quota;
  • a book(hours) method that validates and adds, throwing ValidationError if the quota is exceeded.

Exercise 2 — From _ to #. Convert this legacy class to private fields, getters and setters, and find the encapsulation flaw that the underscore was letting through.

class Budget {
  constructor(limit) {
    this._limit = limit;
    this._lines = [];
  }
  get total() { return this._lines.reduce((s, l) => s + l.amount, 0); }
  get lines() { return this._lines; }
  add(item, amount) {
    if (this.total + amount > this._limit) throw new Error('The limit is exceeded.');
    this._lines.push({ item, amount });
  }
}

Exercise 3 — Serialization with private fields. Starting from the Task class in section 6, add toJSON(), static fromJSON(data) and a clone() method. Show with code that: (a) without toJSON three fields are lost; (b) with toJSON the round trip preserves everything; (c) the clone is independent of the original —changing one's status does not affect the other— and (d) structuredClone fails.

Solutions

Exercise 1

'use strict';

class Resident {
  static #QUOTAS = { monthly: 40, daily: 8 };

  #plan;
  #bookedHours = 0;

  constructor(name, plan) {
    Object.defineProperty(this, 'name', { value: name, writable: false, enumerable: true, configurable: false });
    this.plan = plan;                              // goes through the setter: validates
  }

  get plan()           { return this.#plan; }
  get bookedHours()    { return this.#bookedHours; }
  get quota()          { return Resident.#QUOTAS[this.#plan]; }
  get hoursAvailable() { return this.quota - this.#bookedHours; }
  get hasHoursLeft()   { return this.hoursAvailable > 0; }

  set plan(next) {
    if (!Object.hasOwn(Resident.#QUOTAS, next)) {
      throw new ValidationError(`Unknown plan: "${next}".`, 'plan', next);
    }
    if (this.#bookedHours > Resident.#QUOTAS[next]) {
      throw new ValidationError(
        `Cannot switch to "${next}": there are already ${this.#bookedHours} h booked and the quota is ${Resident.#QUOTAS[next]}.`,
        'plan', next);
    }
    this.#plan = next;
  }

  book(hours) {
    if (typeof hours !== 'number' || hours <= 0) {
      throw new ValidationError('Hours must be a positive number.', 'hours', hours);
    }
    if (this.#bookedHours + hours > this.quota) {
      throw new ValidationError(
        `${this.name} only has ${this.hoursAvailable} h available.`, 'hours', hours);
    }
    this.#bookedHours += hours;
    return this;
  }
}

const lucia = new Resident('Lucía', 'monthly');
lucia.book(14).book(12);
console.log(lucia.bookedHours, lucia.hoursAvailable, lucia.hasHoursLeft);   // 26 14 true

try { lucia.plan = 'daily'; }
catch (e) { console.error(e.message); }   // Cannot switch to "daily": there are already 26 h booked and the quota is 8.

try { lucia.name = 'Lucia'; }
catch (e) { console.error(e.constructor.name); }   // TypeError

try { lucia.book(20); }
catch (e) { console.error(e.message); }   // Lucía only has 14 h available.

The interesting detail is the plan setter: it does not just validate the incoming value, but the consistency with the current state. That is the kind of invariant that can only be protected from inside the object, because only it knows both pieces of the data. And notice that the constructor assigns this.plan = plan instead of this.#plan = plan: one rule, one place.

Exercise 2

The flaw was in the lines getter: it returned the internal array, so anybody could bypass the limit check completely.

const b = new Budget(500);
b.add('Screen-printing ink', 120);
b.lines.push({ item: 'New lathe', amount: 9000 });   // ✗ nobody checks anything
console.log(b.total);                                 // 9120, with a limit of 500

Corrected version:

'use strict';

class Budget {
  #limit;
  #lines = [];

  constructor(limit) {
    if (typeof limit !== 'number' || limit <= 0) {
      throw new ValidationError('The limit must be a positive number.', 'limit', limit);
    }
    this.#limit = limit;
  }

  get limit()     { return this.#limit; }
  get total()     { return this.#lines.reduce((s, l) => s + l.amount, 0); }
  get available() { return this.#limit - this.total; }
  get lines()     { return this.#lines.map((l) => ({ ...l })); }   // 2-level copy

  add(item, amount) {
    if (this.total + amount > this.#limit) {
      throw new ValidationError(
        `The limit is exceeded: ${this.available} € left and ${amount} € requested.`, 'amount', amount);
    }
    this.#lines.push({ item, amount });
    return this;
  }
}

const b = new Budget(500);
b.add('Screen-printing ink', 120).add('Bookbinding paper', 80);
b.lines.push({ item: 'New lathe', amount: 9000 });   // mutates a copy

console.log(b.total, b.available);       // 200 300   ← the budget never noticed
try { b.add('New lathe', 9000); }
catch (e) { console.error(e.message); }  // The limit is exceeded: 300 € left and 9000 € requested.

Look at get lines(): [...this.#lines] is not enough, because that would copy the array but share the objects inside —the shallow copy from 04-08— and somebody could do b.lines[0].amount = 9000. That is why it uses map((l) => ({ ...l })), which copies each line as well. It is exactly the right depth: one level more because the lines are plain objects.

Exercise 3

'use strict';

class Task {
  // …private fields and methods from section 6…

  toJSON() {
    return {
      id: this.id, title: this.title, assignee: this.assignee,
      priority: this.priority, status: this.#status,
      tags: [...this.#tags], estimatedHours: this.#hours,
      dueDate: this.dueDate, reviewer: this.reviewer
    };
  }

  static fromJSON(data) {
    return new Task(typeof data === 'string' ? JSON.parse(data) : data);
  }

  clone() {
    return Task.fromJSON(this.toJSON());
  }
}

const original = new Task({
  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'
});

// (a) Without toJSON three fields would be lost. Simulating it with the spread:
console.log(Object.keys({ ...original }));
// [ 'id', 'title', 'assignee', 'priority', 'dueDate', 'reviewer' ]
// missing: status, tags, estimatedHours

// (b) With toJSON, a complete round trip
const text = JSON.stringify(original);
const roundTrip = Task.fromJSON(text);
console.log(roundTrip.status, roundTrip.estimatedHours, roundTrip.tags);
// 'in-progress' 12 [ 'space', 'design' ]
console.log(roundTrip instanceof Task);        // true

// (c) The clone is independent
const copy = original.clone();
copy.changeStatus('done');
console.log(original.status, copy.status);     // 'in-progress' 'done'
copy.tags = ['space'];
console.log(original.tags);                    // [ 'space', 'design' ]  ← untouched

// (d) structuredClone cannot cope with private fields
try { structuredClone(original); }
catch (e) { console.error(e.name); }           // 'DataCloneError'

Three conclusions from this exercise. First, that the spread {...task} from 04-07 does not see private state, exactly like JSON.stringify: the reason is the same, both operate on own enumerable properties. Second, that clone() implemented as fromJSON(toJSON()) is a deep copy that also revalidates: if the original held corrupt data, the clone would fail to build, which is an advantage, not a drawback. And third, that structuredClone stops being an option as soon as there are classes with private fields, which makes the toJSON/fromJSON pair the project's official copying and persistence mechanism.

Conclusion

You have turned a suggestion into a barrier. At the start of the lesson, task.status = 'done' and task.estimatedHours = 500 went straight through all the validation from 05-02 without resistance; now one throws a TypeError because the property only has a getter, and the other throws a ValidationError from the setter. The real state lives in #status, #hours and #tags, fields that cannot even be named from outside the class —the error is syntactic, not runtime— and the rules R2, R3, R5, R6, R8 and R9 are each written in a single place, on the route every value is forced to take.

You have the four tools and you know when to use each. get for computed properties that never drift out of sync (isOpen, daysLeft, openHours, effort), with the criterion of reserving it for cheap, argument-free things and leaving methods for actions and expensive calculations. set to intercept assignments and validate before storing, always remembering that the internal store must have a different name so you do not fall into infinite recursion, and that the constructor must assign through the setter so the rule is not duplicated. #field, #method() and static #field for real privacy, with the brand check #status in object as an authenticity test instanceof cannot offer. And Object.defineProperty with writable, enumerable and configurable for read-only properties like the id, knowing that all three attributes default to false and that this is the source of half the surprises.

You also know where all this comes from: the _field convention that protected nothing, the module-pattern closures from 03-04 that did protect but gave up the prototype, and the external WeakMap of the pre-2022 libraries. You have designed the public API of Board by consciously deciding what is visible —summary, hoursByAssignee, filter, add, changeStatus— and what is hidden —the #tasks array and the #checkWorkload method—, with defensive copies in the getters so that not even a push on what is returned can break the invariants. And you have paid and settled the price of privacy: since # fields are invisible to JSON.stringify, to the spread and to structuredClone, the toJSON / fromJSON pair becomes the official door in and out for data, revalidation included and with the canonical summary intact after the trip: 48 h, 45 open, 1 overdue, effort 124.

The Nómada Tasks model is now complete and protected. But it still lives entirely in a single file: Task, RecurringTask, Board, ValidationError, the constants, the sample backlog and the code that runs it, all mixed together and growing without limit. With loose <script> tags, moreover, every name you declare competes with all the others in the same global space, and load order becomes an implicit dependency nobody has written down anywhere. Sorting that out —splitting the project into files with explicit boundaries that declare what they expose and what they need— is the subject of Modules: Import and Export.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved