Vasu Patel logoVasu Patel
← Objects First: OOP Fundamentals
All chapters in this track
  1. 01Chapter 1: Why Object Orientation Exists
  2. 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
  3. 03Chapter 3: The Four Pillars I: Encapsulation
  4. 04Chapter 4: The Four Pillars II: Abstraction
  5. 05Chapter 5: The Four Pillars III: Inheritance
  6. 06Chapter 6: The Four Pillars IV: Polymorphism
  7. 07Chapter 7: Composition vs. Inheritance
  8. 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
  9. 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
  10. 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
  11. 11Chapter 11: Basic UML for Communicating Design
  12. 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
  13. 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
  14. 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
  15. 15Chapter 15: Design Exercise: Parking Lot System
  16. 16Chapter 16: Design Exercise: Library Management System
  17. 17Chapter 17: Design Exercise: Elevator Controller
  18. 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like

Chapter 3 of 18

Chapter 3: The Four Pillars I: Encapsulation

Why this matters

Encapsulation is the pillar Chapter 1's bank account example was built entirely around, and it's the one with the most direct payoff: it's the difference between a bug being possible and a bug being impossible by construction. Every other pillar in this book builds on the idea that an object controls its own state — encapsulation is where that control is implemented.

The core concept

[BEGINNER] Encapsulation is bundling data with the methods that operate on it, and restricting direct access to that data from outside the object. The object exposes a public interface (methods, and sometimes read-only properties) and hides its internal representation behind it. Callers interact with the interface; they never reach in and touch the internals directly.

Two things are usually confused as "encapsulation" but are really its consequences:

  • Information hiding — callers don't need to know how an object stores its state, only what it can do. This means you can change the internal representation later without breaking callers, as long as the public interface stays the same.
  • Invariant protection — because the only way to change state is through the object's own methods, those methods can validate every change, so the object can never observe itself in an invalid state.

Before — no encapsulation, invariant unprotected:

class TemperatureReading {
  celsius: number;
  fahrenheit: number;

  constructor(celsius: number) {
    this.celsius = celsius;
    this.fahrenheit = celsius * 9 / 5 + 32;
  }
}

const reading = new TemperatureReading(20);
reading.celsius = 100; // fahrenheit is now stale and wrong — no code stops this
console.log(reading.fahrenheit); // still 68, should be 212

The bug here isn't a typo — it's structural. Two fields need to stay in sync, but nothing enforces that; any code with a reference to reading can silently break the invariant.

After — access controlled, invariant computed on demand:

class TemperatureReading {
  private celsiusValue: number;

  constructor(celsius: number) {
    this.celsiusValue = celsius;
  }

  get celsius(): number {
    return this.celsiusValue;
  }

  set celsius(value: number) {
    this.celsiusValue = value;
  }

  get fahrenheit(): number {
    return this.celsiusValue * 9 / 5 + 32; // always derived, never stale
  }
}

const reading = new TemperatureReading(20);
reading.celsius = 100;
console.log(reading.fahrenheit); // 212 — correct, because it can't be otherwise

By making fahrenheit a derived value instead of stored, redundant state — the actual source of the bug — is eliminated rather than merely protected. This is often a better fix than validation: the strongest form of encapsulation is having no invariant left to enforce.

class TemperatureReading:
    def __init__(self, celsius: float) -> None:
        self._celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32

Access modifiers: what "private" actually means in each language

[IN PRACTICE] Access control is enforced very differently depending on the language, and this matters when you're deciding how much to trust it:

LanguageEnforcementNotes
Java, C#Compiler-enforced (private/protected/public)Truly inaccessible from outside the class (barring reflection)
TypeScriptCompiler-enforced at type-check time onlyprivate/protected are erased at runtime — compiled JS has no real privacy; #field (native JS private fields) is runtime-enforced
PythonConvention onlyA single leading underscore (_field) means "internal, don't touch" by convention; double underscore (__field) triggers name-mangling, which deters but doesn't prevent access

[SENIOR INSIGHT] This is a real trade-off, not a footnote: in Python, object._balance = -100 will simply work — there is no compiler standing in the way. Python's culture accepts this ("we're all consenting adults") in exchange for flexibility (easy monkey-patching, mocking in tests, introspection). TypeScript's private catches mistakes at compile time but provides zero runtime protection unless you use the native #field syntax — a library boundary that needs real enforcement (e.g., protecting security-relevant state from a malicious caller) should use #field or a closure, not private.

class SecureToken {
  #value: string; // truly private at runtime, not just at compile time
  constructor(value: string) { this.#value = value; }
  validate(candidate: string): boolean { return candidate === this.#value; }
}

Encapsulation is a spectrum, not a switch

[SENIOR INSIGHT] Encapsulation isn't "everything private" — it's "expose exactly as much as callers legitimately need, and no more." Over-encapsulating (making everything private and adding a getter/setter for every field) recreates the exact problem it was meant to solve: if a setter has no validation and a getter has no logic, you've written more code to achieve the same lack of protection as a public field, just with extra ceremony.

// This "encapsulation" protects nothing — it's a public field wearing a disguise.
class Order {
  private _status: string;
  constructor(status: string) { this._status = status; }
  getStatus(): string { return this._status; }
  setStatus(status: string): void { this._status = status; } // no validation at all
}

A getter/setter pair is only worth its ceremony if it does something a public field cannot: validate, derive, log, lazily compute, or otherwise guard the transition.

Common mistakes

  1. Adding a getter and setter for every field "just in case," with no logic in either. This is ceremony, not encapsulation — see above.
  2. Exposing a mutable internal collection directly. Returning a private array or object by reference lets callers mutate internal state without going through any method:
    class Playlist {
      private songs: string[] = [];
      getSongs(): string[] { return this.songs; } // caller can push/splice this directly!
    }
    
    Fix: return a copy (return [...this.songs]) or a read-only view.
  3. Trusting TypeScript's private as a runtime security boundary. It isn't one — use #field or module-level closures when the boundary actually matters (e.g., across a trust boundary, not just across files on your own team).
  4. Confusing encapsulation with "small classes." Encapsulation is about controlling access to state, not about class size — a large class can still encapsulate its state perfectly well, and a tiny class can still leak everything through a getter that returns a mutable reference.

Checklist: encapsulating well

  1. Make fields private by default; only expose what callers actually need. Start closed, open up deliberately — not the reverse.
  2. Never return a direct reference to mutable internal state. Return a copy, a read-only view, or a value type instead.
  3. Prefer deriving values over storing redundant state. If a field's value can be computed from other fields, compute it — there's no invariant to violate if there's nothing stored to go stale.
  4. Only write a getter/setter when it does real work — validation, derivation, or guarding a transition — not as a reflexive substitute for a public field.
  5. Know your language's actual enforcement mechanism, and use the strongest one available when the boundary genuinely matters (e.g., #field in TypeScript/JS, not just private).

Discussion / interview questions

  1. "What is encapsulation, and what specific problem does it solve?" Strong answer: Bundling data with the methods that protect it, restricting direct access so an invariant can't be violated from outside. Uses a concrete example (like the temperature or bank account case) rather than a definition alone.

  2. "Is a class with private fields and a getter/setter for each one well-encapsulated?" Strong answer: No, if the setters do no validation — that's a public field with extra steps. True encapsulation is judged by what's protected, not by the presence of private.

  3. "How is private enforced differently in TypeScript versus Java versus Python, and why does that matter?" Strong answer: Java: compiler-enforced. TypeScript: compile-time only, erased at runtime (use #field for real enforcement). Python: convention only (_field, __field name-mangling). Matters because a boundary you actually need enforced (e.g. against untrusted callers) can't rely on TypeScript private or Python's underscore convention alone.

  4. "What's wrong with a getter that returns a private array field directly?" Strong answer: Identifies that the caller receives a reference, not a copy, and can mutate the "private" internal state directly, defeating the purpose of making it private in the first place. Names the fix: return a copy or read-only view.

  5. "Give an example where the best fix for a broken invariant is to remove state entirely, rather than add validation." Strong answer: Uses a derived-value example (like Fahrenheit from Celsius, or a cached total that should just be recomputed) — shows understanding that eliminating redundant state is often stronger than defending it.

Chapter summary / cheat sheet

  • Encapsulation = bundle data with the methods that protect it; restrict direct access to force all changes through methods that can validate them.
  • Information hiding (callers don't need internals) and invariant protection (the object can't be put in a bad state from outside) are its two payoffs.
  • Enforcement differs by language: Java/C# (compiler), TypeScript (compile-time only — use #field for runtime enforcement), Python (convention only).
  • Encapsulation is a spectrum: expose exactly what's needed, no more, no less. Empty getters/setters aren't encapsulation.
  • Never leak a mutable reference to internal state through a getter. Prefer deriving values over storing redundant, sync-able-to-go-wrong state.