Contents

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

1. What does this mean inside a method?

Not quite. It is the particular object the method was called on, which is why two counters can count separately using one piece of code.

Yes. c.increment() runs with this being c. Call the same method on another object and this is that one instead.

Not quite. this has nothing to do with files. Chapter 20 covers how code is split across them.

2. A class has a private field. What can you say about it at runtime?

Not quite. private is checked at compile time and then erased. A cast reads it, and console.log prints it.

Yes. The compiled JavaScript has no protection at all. Use a # field when the privacy needs to survive to runtime.

Not quite. Files have nothing to do with it. The compiled field is an ordinary property that anything can read.

3. Why make a field private at all, if a type with plain properties would hold the same data?

Not quite. They do not, and the compiled output for a private field is an ordinary property.

Yes. A public field can be set to anything by any line anywhere. Methods are a door you chose, and the rules live behind it.

Not quite. Fields are public by default. Making one private is a decision you make.

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 { }
}
  • fill builds all 52 cards. Two nested for...of loops, one over suits and one over ranks, pushing a template literal onto #cards.
  • size reports how many are left.
  • deal takes one card off the end with .pop() and gives it back.
  • shuffle rearranges 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 undefined

A 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.