Encapsulation

Think about a cash machine. Behind the little screen sit stacks of banknotes, a secure ledger of your balance, and a lot of careful checking. But you never reach inside it. You press a few buttons — withdraw £20 — and the machine decides whether that's allowed: does the account exist, is there enough money, is the daily limit exceeded? You get to ask for what you want; the machine guards how it's actually done. You could not, even if you tried, lean over and quietly change your balance to a million pounds.

That is encapsulation. In object-oriented programming it means two things bundled into one idea:

The public methods are the object's interface — its buttons. Everything else is hidden machinery. Because nothing outside can poke at the raw data, the object can guarantee its own rules — a bank balance that never goes negative, an email address that is always valid. Those guaranteed-true rules are called invariants, and encapsulation is how an object protects them.

Private fields, a public interface

In TypeScript you hide a field by marking it private. A private field can be read and changed only by code inside the same class — the class's own methods. From outside, it simply isn't reachable. Compare that with a field marked public (the default), which anyone can read or overwrite.

Here is a bank account. The balance is private, so the only way to change it is through deposit and withdraw — and each of those checks the rules first. The account number is readonly: set once when the object is built, never changed again. Press Run.

class BankAccount { private balance: number; readonly accountNumber: string; // public, but can never be reassigned constructor(accountNumber: string, opening: number) { this.accountNumber = accountNumber; this.balance = opening; } deposit(amount: number): void { if (amount <= 0) { // rule: deposits must be positive console.log("Deposit must be positive."); return; } this.balance += amount; } withdraw(amount: number): void { if (amount > this.balance) { // invariant: balance can never go negative console.log("Insufficient funds — withdrawal refused."); return; } this.balance -= amount; } getBalance(): number { // a getter: read-only view of the hidden data return this.balance; } } const acc = new BankAccount("GB-001", 100); acc.deposit(50); console.log("After deposit:", acc.getBalance()); // 150 acc.withdraw(200); // refused — would go negative console.log("After withdraw:", acc.getBalance()); // still 150 acc.withdraw(30); console.log("Final balance:", acc.getBalance()); // 120

The invariant "the balance is never negative" is now impossible to break from outside. No matter what the rest of the program does, it can only ever go through withdraw, and withdraw refuses to overdraw. The object protects itself.

Getters and setters: a guarded door, not an open window

Sometimes the outside world genuinely needs to read or change a piece of state. Encapsulation doesn't forbid that — it routes it. A getter hands back a value (perhaps a computed or copied one); a setter takes a proposed new value and validates it before storing it. The field stays private; the door has a guard on it.

This Person keeps its email private. You can't assign nonsense to it, because the setter rejects anything without an @:

class Person { private _email: string = ""; setEmail(value: string): void { if (!value.includes("@")) { // validate before storing console.log("Rejected: '" + value + "' is not a valid email."); return; } this._email = value.toLowerCase(); // normalise, then store } getEmail(): string { return this._email; } } const p = new Person(); p.setEmail("not-an-email"); // rejected console.log("Email is now:", "'" + p.getEmail() + "'"); // still empty p.setEmail("Aisha@School.UK"); // accepted and normalised console.log("Email is now:", "'" + p.getEmail() + "'"); // aisha@school.uk

Notice the naming convention: the private field is _email (leading underscore) and the public method is getEmail/setEmail. The outside world only ever sees the methods. That freedom is powerful — you could later decide to store the email encrypted, or split it into user and domain, and no calling code would need to change, because it never touched the field directly.

Expose behaviour, not raw data

Good encapsulation isn't just "add a getter and setter for every field." The real skill is offering purposeful operations — verbs the object does — rather than laying its fields bare. A well-encapsulated class reads like a set of sensible requests, not a pile of switches.

class Thermostat { private tempC: number = 20; private readonly MIN = 5; private readonly MAX = 30; warmer(): void { this.tempC = Math.min(this.tempC + 1, this.MAX); // clamped to a safe range } cooler(): void { this.tempC = Math.max(this.tempC - 1, this.MIN); } reading(): string { return this.tempC + "°C"; } } const t = new Thermostat(); for (let i = 0; i < 15; i++) t.warmer(); // keep asking for warmer... console.log("Capped at:", t.reading()); // 30°C — never runs away for (let i = 0; i < 40; i++) t.cooler(); console.log("Floored at:", t.reading()); // 5°C

The user of a Thermostat says warmer or cooler — they never set the temperature to a raw number. So the safe-range invariant (between 5 and 30 °C) holds no matter how the object is used. The behaviour is public; the number behind it is private.

On a tiny program written by one person in an afternoon, you might get away with it. But software grows, gets shared, and lives for years. Every public field is a promise you can never take back: the moment some far-off piece of code writes account.balance = -50, your careful rules are worthless, and you can no longer even find everywhere the balance is changed. Making fields private isn't about distrusting colleagues — it's about making the wrong thing impossible instead of merely discouraged. The compiler becomes your rule-enforcer.

The classic mistake is to make a field public for convenience — and by doing so, throw away all your protection. If the balance is public, any line of code anywhere can write to it directly, sailing straight past your withdraw check:

class LeakyAccount { public balance: number = 100; // public → unprotected withdraw(amount: number) { if (amount <= this.balance) this.balance -= amount; } } const a = new LeakyAccount(); a.balance = -9999; // ALLOWED — the invariant is broken, withdraw() bypassed entirely

Your withdraw guard still exists, but it's pointless: nothing forces anyone to use it. The fix is the whole lesson — make the field private and expose behaviour, not raw data. Two things to remember: