6. Encapsulation and Information Hiding
Chapter 5 said to know as little as possible about other units. This chapter is the other side of that coin: give other units as little as possible to know. Loose coupling is what you get when modules hide their internals — you cannot reach through a wall that isn’t there. Encapsulation is how you build the wall, and the guiding idea is simple: a module’s value is what it lets you ignore.
The Itch
Section titled “The Itch”Here is a shopping cart, written the way objects often start — everything public, because it was easiest:
@dataclassclass ShoppingCart: items: list[LineItem] = field(default_factory=list) discount_rate: float = 0.0 total: float = 0.0
def recompute(self) -> None: subtotal = sum(item.price for item in self.items) self.total = round(subtotal * (1 - self.discount_rate), 2)class ShoppingCart { items: LineItem[] = []; discountRate = 0; total = 0;
recompute(): void { const subtotal = this.items.reduce((sum, item) => sum + item.price, 0); this.total = Math.round(subtotal * (1 - this.discountRate) * 100) / 100; }}It works in the demo. In production it rots, because nothing stops a caller from breaking it:
cart = ShoppingCart()cart.items.append(LineItem("coffee", 12.50)) # total now silently wrongprint(cart.total) # 0.00 — stale, and a liecart.discount_rate = 2.0 # 200% off, cheerfully acceptedcart.recompute() # total is now negative (-12.50)const cart = new ShoppingCart();cart.items.push({ name: "coffee", price: 12.5 }); // total now silently wrongconsole.log(cart.total); // 0 — stale, and a liecart.discountRate = 2.0; // 200% off, cheerfully acceptedcart.recompute(); // total is now negative (-12.50)Every one of these lines is allowed, and that is the whole problem. The object
holds an invariant — total equals the discounted sum of items, and discount_rate
is a fraction between 0 and 1 — but it has no power to enforce it, because every
field is exposed for anyone to set. The cart cannot keep its own promises. That
helplessness, not any single bad line, is the itch.
The Concept
Section titled “The Concept”Encapsulation is bundling state together with the behavior that guards it, and exposing a deliberate public surface over a hidden implementation. Information hiding (David Parnas’s term) is the design judgment behind it: hide the decisions most likely to change — or most able to do harm — so that callers cannot depend on them. What is hidden cannot be coupled to, and cannot be corrupted.
The test is one question: can a caller put this object into a state it should never be in? For the cart above, yes — trivially, three different ways. Each “yes” marks something that should have been hidden behind behavior and wasn’t.
This connects to a deeper idea we will develop fully in Chapter 16. John Ousterhout measures a module by its depth: how much functionality it hides behind how small an interface. A deep module presents a tiny surface and does a lot behind it; a shallow module has an interface nearly as large as its implementation, so it hides almost nothing and barely earns its existence. Depth is the goal, because a deep module is something you can use without understanding — call it through its narrow interface and trust the rest. That is a gift to every reader, and in the AI era to your agent too: a narrow, honest interface is less context to load and fewer ways to be misused. (Chapter 16 makes this precise; here, just register that small interfaces hiding real work are the target.)
Before / After
Section titled “Before / After”The cure is to hide the fields and expose only what callers legitimately need — a surface that makes the illegal states impossible to express.
Before
Section titled “Before”@dataclassclass ShoppingCart: items: list[LineItem] = field(default_factory=list) discount_rate: float = 0.0 total: float = 0.0 # caller must keep this in sync, by hand
def recompute(self) -> None: subtotal = sum(item.price for item in self.items) self.total = round(subtotal * (1 - self.discount_rate), 2)class ShoppingCart { items: LineItem[] = []; discountRate = 0; total = 0; // caller must keep this in sync, by hand
recompute(): void { const subtotal = this.items.reduce((sum, item) => sum + item.price, 0); this.total = Math.round(subtotal * (1 - this.discountRate) * 100) / 100; }}class ShoppingCart: def __init__(self) -> None: self._items: list[LineItem] = [] self._discount_rate: float = 0.0
def add_item(self, item: LineItem) -> None: self._items.append(item)
@property def items(self) -> tuple[LineItem, ...]: return tuple(self._items) # read-only view; can't corrupt us
@property def discount_rate(self) -> float: return self._discount_rate
@discount_rate.setter def discount_rate(self, rate: float) -> None: if not 0.0 <= rate <= 1.0: raise ValueError(f"discount_rate must be in [0, 1], got {rate}") self._discount_rate = rate
@property def total(self) -> float: # computed on read — never stale subtotal = sum(item.price for item in self._items) return round(subtotal * (1 - self._discount_rate), 2)class ShoppingCart { #items: LineItem[] = []; // truly private — unreachable from outside #discountRate = 0;
addItem(item: LineItem): void { this.#items.push(item); }
get items(): readonly LineItem[] { return [...this.#items]; // readonly copy; can't corrupt us }
get discountRate(): number { return this.#discountRate; }
set discountRate(rate: number) { if (rate < 0 || rate > 1) { throw new RangeError(`discountRate must be in [0, 1], got ${rate}`); } this.#discountRate = rate; }
get total(): number { // computed on read — never stale const subtotal = this.#items.reduce((sum, item) => sum + item.price, 0); return Math.round(subtotal * (1 - this.#discountRate) * 100) / 100; }}Now the three illegal states are not forbidden — they are unrepresentable.
total has no setter, so it cannot be assigned a lie; it is computed on every read,
so it cannot go stale. discount_rate rejects anything outside [0, 1]. The item
list is hidden, and what callers get back is a read-only view — a Python tuple, a
TypeScript readonly copy — so mutating the result cannot reach the cart’s real
list. The interface shrank and the guarantees grew — the two moves of good
encapsulation, together. The examples/ch06/ tests assert exactly this: the before
tests demonstrate each leak, and the after tests prove each one impossible.
Language Notes
Section titled “Language Notes”The two languages enforce encapsulation with different tools — one social, one the compiler’s — but the design judgment behind both is the same.
Python has no private keyword. The single leading underscore — _items — is the
consenting-adults convention: a sign that says “implementation detail, don’t
touch,” not a wall that stops you. Nothing prevents a determined caller from reaching
cart._items; encapsulation in Python is a social contract the team keeps, not one
the compiler enforces. (Double-underscore name mangling exists, but it is for
avoiding subclass name clashes, not for security — reach for it rarely.)
This frees Python from a ritual other languages perform up front. Do not write
getters and setters by default. Start with a plain public attribute. If an
invariant or a hidden decision later appears, upgrade that attribute to a
@property — and no call site changes, because cart.total reads identically
whether total is a field or a computed property. This is the uniform access
principle, and it is why premature accessors are pure waste in Python: you can
always add the guard later, exactly when you have a reason. For “make this
immutable,” a frozen=True dataclass turns a value object into something that
cannot be altered after construction — the strongest encapsulation of all, because
there is no mutation to guard.
TypeScript gives you a real wall where Python gives you a sign. A field named with a
# — #items — is a truly private field: it is unreachable from outside the
class, invisible to Object.keys, and a syntax error to access elsewhere. This is
the genuine article, not the older private keyword, which is erased at compile time
and leaves the field reachable at runtime. When you mean private, prefer #.
class ShoppingCart { #items: LineItem[] = []; // no caller can reach this get items(): readonly LineItem[] { return [...this.#items]; // hand back a readonly copy, not the original } get total(): number { /* computed on read — no setter, so no lie */ } set discountRate(rate: number) { /* validate on the way in */ }}The protection comes from three tools used together. get/set accessors read
like plain fields at the call site (cart.total) — TypeScript’s uniform access — so
a getter with no setter is a read-only property the type checker enforces. readonly
marks a field or array view immutable; returning readonly LineItem[] (or a copy)
stops a caller’s .push from reaching your internals. And for whole values that
should never change, Readonly<T> or an as const literal freezes the shape at
the type level — the value-object move, the same intent as Python’s frozen
dataclass.
Hand back copies or
readonlyviews, never the live collection. A getter that returnsthis.#itemsdirectly leaks the private array;[...this.#items]or areadonlyview keeps the wall intact.
When NOT to Use
Section titled “When NOT to Use”🤖 AI Collaboration
Section titled “🤖 AI Collaboration”Agents lean toward two opposite errors here, and which one you get depends on the prompt. A terse request often yields leaky public state; a request for “proper encapsulation” often yields a wall of accessors. Aim between them.
Vocabulary
Section titled “Vocabulary”| You say | The agent hears |
|---|---|
| ”Narrow the public API” | Expose only what callers need; hide the rest (Python _, TS #) |
| “Hide the implementation” | Put state behind behavior that guards its invariants |
”Make total a read-only property” | Compute on read; no setter, so it can’t be set wrong |
| ”Make this immutable” | A value object with no setters — frozen dataclass / readonly fields, no mutation to guard |
| ”Is this module deep or shallow?” | Weigh hidden functionality against interface size |
Prompt templates
Section titled “Prompt templates”This class exposes mutable state that lets callers reach illegal states. Identify its
invariants, then narrow the public API so those states are unrepresentable: hide the
raw fields (Python _, TypeScript #), expose computed values as read-only
properties, and validate on the way in. Do not add getters and setters for fields
that have no invariant — plain public fields are fine for those.
Turn this into an immutable value object with no setters — a frozen=True dataclass
in Python, readonly fields (or Readonly<T>) in TypeScript. If any field needs
validation, enforce it at construction. Keep it plain data — no behavior beyond what
the value itself owns.
Review checklist
Section titled “Review checklist”- Can a caller reach an illegal state? (try it — set the field, mutate the list)
- Any cached field that should be a computed property instead?
- Any mutable internal leaked — a returned
list/dictthat aliases private state? - Reverse check: any getter/setter ceremony that hides or guards nothing?
- Is the public surface small relative to what the class actually does?
Agent failure modes
Section titled “Agent failure modes”- Accessor ceremony. Asked for encapsulation, the agent writes a getter/setter pair for every field — hiding nothing, since each setter just stores what the field would have. Counter: use properties only where there’s an invariant; plain public fields otherwise.
- The leaked internal. It returns the private list directly, so a caller’s
.append()/.push()mutates it. Return a copy or a read-only view. - The shallow wrapper. It “encapsulates” by building a class that just forwards to the thing inside it — a bigger interface, no hidden work. That is a shallow module; ask what it actually hides.
- Over-privatizing. It hides fields that had no invariant, then adds accessors to get them back — motion without protection.
Key Takeaways
Section titled “Key Takeaways”- Encapsulation bundles state with the behavior that guards it; information hiding is choosing what callers must not be able to depend on. What is hidden cannot be coupled to (Chapter 5) or corrupted.
- The test is one question: can a caller reach a state this object should never be in? Good encapsulation makes illegal states unrepresentable, not merely discouraged.
- Each language has its own tools: Python hides with the
_convention (consenting adults — a signal, not a wall) and@property; TypeScript has truly private#fields,readonly, and getters. Both expose computed values as read-only properties and offer an immutable value-object form (frozendataclass /readonlyfields). Don’t write getters/setters up front — upgrade a public field to a property later, with no call site changing. - A module’s worth is its depth: much functionality behind a small interface (Ousterhout; Chapter 16). A narrow, honest surface is less for any reader — human or agent — to hold.
- Right-size it: plain-data value objects should stay public; ceremony that guards no invariant is worse than nothing.
- Glossary terms added: encapsulation · information hiding · public API (public surface) · immutability. (Deep and shallow modules are previewed here; their entries arrive with Chapter 16.)