Classes and Objects
Fields, constructors, methods and access modifiers; how a class is both a value and a type; and the many problems a plain function and an object type solve better.
Fields, constructors, methods and access modifiers; how a class is both a value and a type; and the many problems a plain function and an object type solve better.
You have a Photo type and eleven functions that all take one
as their first argument. caption(photo), isLarge(photo),
resize(photo, width). The data and the things you do to it
live in different places, and every new operation is another
function with the same first parameter.
A class puts them together. By the end of this lesson you will write one, know what the access modifiers actually protect, and — because this matters more than the syntax — know the several common cases where a class is the wrong answer.
class Photo {
constructor(
public readonly name: string,
public readonly city: string,
public size: number,
) {}
caption(): string {
return `${this.name.replaceAll("_", " ")}, ${this.city}`;
}
isLarge(): boolean {
return this.size > 1000;
}
}const photo = new Photo("sunrise_over_lisbon.jpg", "Lisbon", 1450);
photo.caption(); // "sunrise over lisbon.jpg, Lisbon"
photo.isLarge(); // true
photo.size = 1600; // allowed - not readonly
photo.name = "x"; // error - readonlyA class describes a kind of thing. new Photo(...) builds
one, called an instance. Functions inside are methods,
and this refers to the instance the method was called on.
That constructor form is worth pointing out. Writing public or
readonly on a constructor parameter declares the property and
assigns it in one step. The longhand is:
class Photo {
readonly name: string;
constructor(name: string) {
this.name = name;
}
}The short form is standard and removes the repetition of naming every field three times.
class Photo {
public name: string; // the default - reachable from anywhere
private key: string; // only inside this class
protected raw: Buffer; // this class and its subclasses
readonly id: string; // cannot be reassigned after construction
}private is checked at compile time and erased. At runtime
the property is an ordinary one, reachable by anyone who looks:
photo.key; // error from the checker
(photo as any).key; // works at runtimeFor genuine privacy there is a separate mechanism with a #
prefix:
class Photo {
#apiKey: string;
constructor(key: string) {
this.#apiKey = key;
}
}
photo.#apiKey; // a syntax error outside the class - alwaysCompile-time only. Erased.
At runtime it is an ordinary property, so
(photo as any).key reads it and JSON.stringify includes
it.
Right for stating design intent — this is not part of the public surface.
Genuinely inaccessible, at runtime too.
Reading it from outside the class is a syntax error, not a type error, so no cast can get around it.
Right when the value must not be reachable regardless of what somebody writes.
A getter is a method called as though it were a field. The value is computed each time, so it cannot go stale — which is the main reason to use one, for anything derived from other fields.
A setter can validate on assignment:
Keep getters cheap. Something that reads as a property and takes a second is a surprise, and the caller has no way to know.
extends means RawPhoto gets everything Photo has. super(...)
runs the parent's constructor and must be called before using
this. super.caption() calls the parent's version rather than
replacing it.
override is a keyword worth turning on with
"noImplicitOverride": true: it makes the checker verify that
you are actually overriding something, so renaming a method in
the parent produces an error rather than a subclass method that
is silently never called.
Inheritance is genuinely useful and routinely overused. The
question is whether the child is a kind of the parent and
can be used anywhere the parent can. A RawPhoto is a Photo,
so this is fine. class PhotoDatabase extends Photo because the
database contains photos is not — a database is not a kind of
photo, and inheriting gives it every photo method, none of which
make sense.
When unsure, hold an object rather than inheriting from it.
Bad — a class that is a function wearing a costume.
Good — a function.
The class holds no state that lives between calls — separator
is an argument moved somewhere less visible. So the object
exists only to be constructed and immediately used: two steps
and an extra name to do what one call does. It is also harder to
import, harder to test, and cannot be passed to map without
wrapping.
Three tests catch nearly every class that should not have been one, and each points at what to write instead.
That is a function.
Nothing lives between calls, so the object exists only to be constructed and immediately used.
It is also harder to import, harder to test, and cannot be
passed to map without wrapping.
A type and a plain object.
Same checking, less ceremony — and a plain object can be
spread, serialised with JSON.stringify and compared field
by field. A class instance does none of those as cleanly.
That is a module.
A class you never instantiate, holding static methods, is a
module with extra punctuation around it.
A class declaration creates a value and a type:
The type is structural, like everything else — anything with the
right shape satisfies it, whether or not it came from that
class. implements is a check that a class has what a type
requires, reported where you wrote it:
And instanceof narrows a union, which is how classes fit the
narrowing lesson:
That is the one place classes are clearly better than plain
objects: errors, where instanceof gives you a reliable check
that survives being thrown across a call stack.
You can now define a type of your own carrying data and behaviour, control what is reachable, and extend one class from another. Just as valuable, you have three tests for when not to — because most code here is functions, types and modules, with classes where they earn their place.
Next is Errors and Exceptions, which is where classes turn
out to be genuinely the right tool. It covers throwing and
catching, why a caught value is unknown rather than an
Error, and the empty catch block that turns a bug into a
mystery.
Before you move on, take a group of values your code passes around with several functions operating on them, and write it both ways — as a class with methods, and as a type with functions. Then look honestly at which reads better. Sometimes it is the class; often it is not, and noticing that is the skill this lesson is actually teaching.
DECLARING
class Photo {
constructor(
public readonly name: string, declares AND assigns
private size: number,
) {}
caption(): string { return this.name; }
get ratio(): number { ... } read as a property
}
new Photo("dawn.jpg", 1450)
MODIFIERS
public the default
private compile-time only - erased, reachable at runtime
protected this class and subclasses
readonly cannot be reassigned after construction
#field GENUINELY private, exists at runtime
EXTENDING
class RawPhoto extends Photo
super(...) the parent constructor, before using this
super.method() the parent's version
override turn on noImplicitOverride
is the child really a KIND of the parent?
otherwise HOLD an object instead of inheriting
WHEN NOT TO USE A CLASS
constructor + one method -> that is a function
data with no behaviour -> a type and a plain object
methods that ignore `this` -> that is a module
use one when data and behaviour belong together AND the
data outlives one call
this
decided at the CALL SITE, not where the method was written
const fn = photo.caption; fn(); -> this is undefined
fix: an arrow-function property, or call it as p.caption()
AS TYPES
a class is a value AND a type
the type is structural - anything matching satisfies it
implements a check, reported where you wrote it
instanceof narrows a union - the best case for classes,
especially for errorsclass Photo {
constructor(
public readonly width: number,
public readonly height: number,
) {}
get aspectRatio(): number {
return this.width / this.height;
}
}
photo.aspectRatio; // no parentheses - reads like a property private _size = 0;
set size(value: number) {
if (value <= 0) throw new Error(`size must be positive: ${value}`);
this._size = value;
}
get size(): number {
return this._size;
}class RawPhoto extends Photo {
constructor(
name: string,
city: string,
size: number,
public readonly camera: string,
) {
super(name, city, size);
}
override caption(): string {
return `${super.caption()} (${this.camera})`;
}
}class CaptionMaker {
constructor(private separator: string = "_") {}
make(filename: string): string {
return filename.replaceAll(this.separator, " ");
}
}
const maker = new CaptionMaker();
const caption = maker.make("sunrise_over_lisbon.jpg");function makeCaption(filename: string, separator = "_"): string {
return filename.replaceAll(separator, " ");
}
const caption = makeCaption("sunrise_over_lisbon.jpg");const fn = photo.caption;
fn(); // 'this' is undefined - a runtime errorcaption = (): string => `${this.name}, ${this.city}`;function render(photo: Photo): string { ... } // the type
const photo = new Photo(...); // the valueclass Photo implements Captionable {
caption(): string { ... }
}if (error instanceof NotFoundError) {
// error is NotFoundError here
}