Chapter 16
Constructors, and Objects That Can't Be Broken
Initialization, invariants, and readonly.
Chapter 15’s Deck ended with a flaw pointed straight at:
const empty = new Deck();
console.log(empty.size(), empty.deal());
0 undefined
A deck with no cards in it. Perfectly legal, completely useless, and the
only thing preventing it was you remembering to call fill().
Removing that “remembering” is what this chapter is about.
A constructor
class Temperature {
#celsius: number;
constructor(celsius: number) {
this.#celsius = celsius;
}
inFahrenheit(): number {
return this.#celsius * 9 / 5 + 32;
}
}
const t = new Temperature(100);
console.log(t.inFahrenheit());
212
constructor is a method with a fixed name that runs when the object is
built. new Temperature(100) calls it with 100, and by the time new
hands you the object, #celsius is already set.
That’s the whole idea: there is no moment at which a half-built object
exists. You cannot get a Temperature that hasn’t got a temperature,
because the only way to make one goes through the constructor.
And the compiler now enforces what’s needed:
const t = new Temperature();
error TS2554: Expected 1 arguments, but got 0.
Chapter 15’s Deck couldn’t do that. new Deck() was fine, and the
problem only showed up later, somewhere else, as an undefined.
The shorthand
Assigning every parameter to a field of the same name gets repetitive fast:
class Point {
#x: number;
#y: number;
constructor(x: number, y: number) {
this.#x = x;
this.#y = y;
}
}
Each name appears three times. TypeScript has a shorthand for exactly this, and you’ll see it constantly:
class Point {
constructor(private x: number, private y: number) {}
describe(): string {
return `(${this.x}, ${this.y})`;
}
}
console.log(new Point(3, 4).describe());
(3, 4)
Put private (or readonly, or public) on a constructor parameter and
TypeScript declares the field and assigns it for you. The constructor body
is genuinely empty because there’s nothing left to write.
These are parameter properties, and they’re not magic. Look at the compiled output:
class Point {
x;
y;
constructor(x, y) {
this.x = x;
this.y = y;
}
describe() { return `(${this.x}, ${this.y})`; }
}
Exactly the long version, written out for you.
readonly
Some things shouldn’t change after the object is built. A book’s title, a point’s coordinates, an order’s ID.
class Book {
constructor(readonly title: string, private pages: number) {}
}
const b = new Book("Dune", 412);
console.log(b.title);
b.title = "Other";
error TS2540: Cannot assign to 'title' because it is a read-only property.
readonly is const for fields. It can be set in the constructor and never
again, so a Book can be read by anyone and changed by nobody, which is
often exactly the right shape.
Note that title here is readable from outside while pages is not.
Public and immutable is a perfectly good combination, and usually a better
default than private-with-a-getter.
Invariants
Here’s the idea the whole chapter is built around.
An invariant is something that is true about an object for its entire life. Not “usually true”, not “true if the caller is careful”. Always true, from the moment it’s made until it’s thrown away.
A thermostat’s target is between 5 and 30 degrees. A deck has 52 cards or fewer. A page count is never negative.
Naming it in a sentence is most of the work. Once you can say it, there are exactly two places to defend it: the constructor, and every method that changes anything.
function clampTarget(n: number): number {
if (n < 5) return 5;
if (n > 30) return 30;
return n;
}
class Thermostat {
#target: number;
constructor(target: number) {
this.#target = clampTarget(target);
}
setTarget(n: number): void {
this.#target = clampTarget(n);
}
getTarget(): number {
return this.#target;
}
}
const t = new Thermostat(100);
console.log(t.getTarget());
t.setTarget(-40);
console.log(t.getTarget());
t.setTarget(21);
console.log(t.getTarget());
30
5
21
Ask for 100 and you get 30. Ask for -40 and you get 5. There is no way to put this object in a state that breaks the rule, because both doors are guarded and there are only two doors.
That is what # bought you. A public target field would mean the
invariant is a comment, and t.target = 500 would sail past every check
you wrote.
Exercise 1 · State the invariant first
Take chapter 12’s Book and turn it into a class whose page count can
never be negative and whose title can never be empty.
Before writing any code, write the invariant as one sentence in a comment at the top of the class. Then make the constructor and any method that changes those fields both uphold it.
You’ll find the sentence is the hard part and the code is easy, which is the usual ratio.
Check yourself
Project
A deck that deals itself
Roughly 45 minutes
Two parts. The first fixes chapter 15’s flaw, the second builds something new around a rule.
Part one: a deck that is never empty by accident.
Take your Deck from chapter 15 and delete fill(). Move its work into
a constructor, so a Deck arrives with 52 cards and there is no way to
make one that hasn’t.
const d = new Deck();
console.log(d.size());52No fill() call. Nothing to forget. Confirm the flaw is gone by trying
to reproduce it: there should be no sequence of calls that gets you a
deck with 0 cards in it other than dealing all 52.
Then add a reset() method that refills it. Notice that reset and the
constructor want to do the same thing, and that a private method called
by both is how you avoid writing it twice, which is chapter 9’s lesson
arriving inside a class.
Part two: a hand that can’t hold too many cards.
Write a Hand class with this invariant, and write it as a sentence in a
comment first:
A hand holds between 0 and 5 cards.
It needs add(card: string), size(), and cards() returning a copy.
Adding to a full hand must not work, and you get to decide what “not
work” means, silently ignore it, or give back a boolean saying whether
it went in. Pick one deliberately and write down why.
Then try to break it. Deal six cards into a five-card hand and check
the size is still 5. Get the copy from cards(), push onto it, and check
the hand is unaffected. Both of those are the chapter under test.
Stretch: give Hand a readonly maxSize set in the constructor, so
a five-card hand and a seven-card hand are the same class with different
limits. The invariant is now “between 0 and maxSize”, which is still
one sentence, and the code barely changes. Then ask what should happen if
somebody constructs a Hand with a maxSize of -1, and notice you’ve
just found a second invariant on the same class.