Chapter 15
Classes: Types You Design Yourself
Methods, and why fields go private.
Chapter 12’s Book type describes a shape. Chapter 13 then showed that
anyone holding one of those objects can reach in and change any field they
like, and chapter 14’s project depended on you remembering to spread before
sorting.
There’s a pattern in all of that: the data is in one place and the rules about it are somewhere else, in your head.
A class
class Counter {
count = 0;
increment(): void {
this.count = this.count + 1;
}
describe(): string {
return `count is ${this.count}`;
}
}
A class is a description of a kind of thing: what it holds, and what it
can do. count is a field, the same as a property on an object.
increment and describe are methods, which are functions that live
inside the class.
Making one needs new:
const c = new Counter();
c.increment();
c.increment();
console.log(c.count);
console.log(c.describe());
2
count is 2
new Counter() builds a fresh counter with count at 0. Every one you
make is separate, so two counters count independently.
this
Inside a method, this means “the object this method was called on”.
c.increment() runs increment with this being c. Call it on a
different counter and this is that one instead. It’s how one piece of
code written once works on every object of that class.
You need this every time you touch a field. Writing count = count + 1
inside the method doesn’t work, TypeScript will tell you count doesn’t
exist, because a bare name means a variable and fields aren’t variables.
Why bother, given chapter 12
You could write all of that with a type and some functions, and for a
counter you probably should. Two things change as the thing gets bigger.
The functions live with the data. c.describe() is findable from c.
A describe(c) function could be in any file in the project, and there’s
nothing about the object that points at it.
You can decide what’s off limits. That’s the part a type genuinely
cannot do.
private
class Account {
private balance = 100;
getBalance(): number {
return this.balance;
}
}
const a = new Account();
console.log(a.balance);
error TS2341: Property 'balance' is private and only accessible
within class 'Account'.
private means “inside this class only”. Methods of Account can use
balance freely; nothing else can, and TypeScript enforces it.
That matters because a balance isn’t just a number, it’s a number with
rules. Money moves in and out through deposits and withdrawals, both of
which might need checking. Leave the field public and any line anywhere can
set it to -4000 and skip every rule you wrote.
Making it private means there is exactly one door in, the methods you wrote on purpose, and all your rules can live behind it.
Actually private
JavaScript has its own private fields, and they survive to runtime. They’re
spelled with a #:
class Account {
#balance = 100;
getBalance(): number {
return this.#balance;
}
}
const a = new Account();
console.log(a.getBalance());
console.log(a);
100
Account {}
Account {}. The field isn’t hidden from you by politeness, it genuinely
cannot be reached from outside, and there’s no cast that gets around it.
Object.keys(a) gives an empty list and JSON.stringify(a) gives {}.
The # is part of the name, so it’s this.#balance everywhere, every time.
That looks strange for about a day.
Which to use. private reads better and is what most TypeScript code
uses, because in a codebase where everything is checked, a compile-time rule
is enough. Use # when the privacy has to be real: something a mistake
outside your code could genuinely corrupt, or anything that will be handed
to code that isn’t type-checked.
This book uses # from here on, because a chapter about enforcing your own
rules should use the version that actually enforces them.
Exercise 1 · Break it both ways
Write a small class twice, once with private secret = 42 and once with
#secret = 42, each with a method that returns it.
For each one, try three things: read the field directly, read it with
(obj as any).secret, and console.log the whole object.
The compile error is the same. Everything after compiling is different, and seeing that once is the point of this exercise.
Check yourself
Project
A deck of cards
Roughly 45 minutes
Build a Deck class that owns its cards completely. Chapter 16 turns this
into something that can’t be built wrong, so keep the file.
The deck holds an array of card strings, "A of Hearts" and so on, built
from two arrays of your own: the four suits, and the thirteen ranks.
class Deck {
#cards: string[] = [];
fill(): void { }
size(): number { }
deal(): string | undefined { }
shuffle(): void { }
}fillbuilds all 52 cards. Two nestedfor...ofloops, one over suits and one over ranks, pushing a template literal onto#cards.sizereports how many are left.dealtakes one card off the end with.pop()and gives it back.shufflerearranges them.this.#cards.sort(() => Math.random() - 0.5)is the one-line version, and it’s good enough here. It is not a properly fair shuffle, and it’s worth knowing that about it.
Note the return type on deal. .pop() on an empty array gives
undefined, so string | undefined is the truth about what this method
can hand back, exactly like .find() in chapter 14. Declare it as plain
string and TypeScript will stop you, correctly. Callers then need the
if (card) check, which is the honesty being passed along rather than
hidden.
Check the deck is really sealed. After fill(), try to reach the
cards from outside:
const d = new Deck();
d.fill();
console.log(d.size());
console.log(d);size() should say 52 and the console.log should show you nothing
useful. That’s the chapter’s point made concrete: the only things anybody
can do to this deck are the four you chose.
Then deal a hand. Deal five cards into an array, print them, and
print the size again. It should be 47. Deal in a loop, and notice that
deal is doing the work of remembering where you’d got to, which is
exactly the kind of bookkeeping a caller shouldn’t have to do.
The flaw, which is deliberate. A brand-new Deck has no cards until
somebody calls fill(), and nothing reminds you to do it:
const empty = new Deck();
console.log(empty.size(), empty.deal());0 undefinedA deck that exists but isn’t ready is a real object in a useless state, and the only thing standing between you and it is remembering.
Hold that thought. Removing the need to remember is the whole of chapter 16.
Stretch: add cardsLeft() returning a copy of the remaining cards,
so a caller can look without touching. Chapter 13 tells you exactly what
to write, and what happens if you return this.#cards directly instead.