Encapsulation

Beginner · 9 min read · ▶ live playground · ✦ checkpoint

A pottery workshop has two seats. The signup screen checks the limit, but another part of the program pushes a third name straight into the attendee list. The screen was careful; the object was not.

Encapsulation means bundling data with the methods that control how that data changes. Visibility is the access rule around a field or method: public members are available to callers, while private members are available only inside the class. Together, they let an object offer useful actions without handing every caller its internal controls.

The rule we need to protect is an invariant, a condition that must remain true for every valid state of an object. For a WorkshopRoster, the number of attendees must never exceed the capacity.

Put the rule on the path to the data

The roster does not need callers to understand array operations. It needs them to ask for a reservation. That public action becomes the one path that may change the private list.

class diagram

A roster guards its capacity

A roster guards its capacity
  1. 1The minus signs keep `attendees` and `capacity` private inside WorkshopRoster.
  2. 2`reserve()` is the public path: it checks the request before changing `attendees`.
  3. 3The checks keep `attendees.length <= capacity` true after every public call.

The minus signs hide how the state is stored, but hiding alone is not the payoff. The important move is pairing private state with public behavior that preserves the rule.

Establish the invariant, then preserve it

A guard is a check that rejects an invalid change before the object changes its state. The constructor guards the starting capacity, and reserve() guards every later addition.

class WorkshopRoster {
  private attendees: string[] = [];
  private capacity: number;
 
  public constructor(capacity: number) {
    if (capacity < 1) {
      throw new Error("Capacity must be at least 1.");
    }
 
    this.capacity = capacity;
  }
 
  public reserve(name: string): boolean {
    if (this.attendees.includes(name)) {
      return false;
    }
 
    if (this.attendees.length >= this.capacity) {
      return false;
    }
 
    this.attendees.push(name);
    return true;
  }
 
  public spotsLeft(): number {
    return this.capacity - this.attendees.length;
  }
 
  public names(): string[] {
    return [...this.attendees];
  }
}
 
const roster = new WorkshopRoster(2);
 
console.log(roster.reserve("Leena"));
console.log(roster.reserve("Omar"));
console.log(roster.reserve("Noah"));
console.log(roster.spotsLeft());

true true false 0

The constructor starts with an empty attendee list and a capacity of at least one, so the invariant is true at creation. reserve() checks for a duplicate and a full workshop before push() runs. The only line that grows the list sits after both guards.

That ordering is the design. On failure, the method returns false and the state stays unchanged. On success, one name is added and the count is still at most the capacity. As a result, spotsLeft() cannot become negative through the public operations this class offers.

Private visibility also reduces the number of places you must inspect. When the attendee count is wrong, you look inside WorkshopRoster instead of searching every caller for a direct array edit.

Private is a boundary, not a decoration

This version communicates two different permissions:

  • Callers may ask reserve(), spotsLeft(), and names() questions through public methods.
  • Only WorkshopRoster may replace or change attendees and capacity directly.

The boundary serves the rule. Adding private to every field without designing meaningful public behavior only moves the same risk behind more method calls.

Do not leak a private collection

The names() method returns [...this.attendees], a new array containing the same names. The three dots are spread syntax, syntax that copies the items into that new array. The copy matters because an array is a mutable value, a value whose contents can change after creation.

Encapsulation can fail through a returned value even when the field itself says private. Trace every public method that exposes internal state. Numbers and strings are safe to return as values here; a mutable array needs a copy so callers cannot change the original.

When not to write a getter and setter for everything

A getter is a method that returns a field's value, while a setter is a method that replaces that value. They are not protection by themselves. An unrestricted setter can recreate public-field access with extra syntax.

class LeakyRoster {
  private capacity = 2;
  private attendees: string[] = [];
 
  public getAttendees(): string[] {
    return this.attendees;
  }
 
  public setAttendees(attendees: string[]): void {
    this.attendees = attendees;
  }
}
 
const leakyRoster = new LeakyRoster();
leakyRoster.setAttendees(["Leena", "Omar", "Noah"]);
 
console.log(leakyRoster.getAttendees().length); // 3

The field is private, yet the public setter accepts an over-capacity list and the getter returns the same array for further edits. A class that stores data but owns no meaningful rules is an anemic model, a model whose decisions live in its callers.

Do not respond by banning every getter. spotsLeft() gives callers useful information without exposing storage, and names() returns a safe copy. The test is whether the public method protects the object's meaning. Prefer reserve(name) over setAttendees(names) because the first names a valid action and owns its rule.

Some data has no changing rule to protect. The ingredient amount from the previous lesson is still clear as a plain value. Encapsulation earns its weight when an object has valid and invalid states, not when it adds ceremony around every field.

Checkpoint

Answer all three to mark this lesson complete

Callers can now ask a roster to reserve a seat without knowing how its array is stored or checked. Next, you will make that smaller public view the main design tool: an abstraction that says what is available while hiding how it works.

+50 XP on completion