13 β Classes β Fields, Constructors and Methods
Level: Intermediate Β· Time: ~30 min Β· Prerequisites: Lesson 12 β Modules, Imports and Project Structure
Why this matters
Some values must never exist in a broken state: a capacity in kilowatts, a reading in kWh, a date that
is honestly a date. Interfaces cannot enforce that, because an object literal can always be built with
nonsense while the compiler looks the other way. A class with validation in its constructor is the
cheapest way to make an invalid value unrepresentable β if you hold a SolarPlant, it is a plant
that passed its checks. Classes also put behaviour next to the data, and give initialisation one place
to happen.
Click the diagram to open it full size.
What a class is at runtime
Under target: ES2022 the class syntax is emitted almost unchanged, with every annotation erased. What
matters is where things live: fields belong to the instance, methods belong to the prototype.
// src/domain/meters.ts
export class Meters {
readonly value: number;
constructor(value: number) {
if (!Number.isFinite(value) || value < 0) throw new RangeError(`got ${value}`);
this.value = value;
}
add(other: Meters): Meters { return new Meters(this.value + other.value); }
}
new Meters(41.2) creates an object whose prototype is Meters.prototype: add exists once for every
instance that will ever be created, while value is per instance. A class is a constructor function
plus a prototype β the keyword is syntax over that structure, not a new kind of thing in the language.
Fields, constructors and parameter properties
A field without an initialiser is undefined until the constructor sets it, and strict refuses to
read it before it is assigned. Assigning every field by hand is noise, so TypeScript offers
parameter properties: a modifier on a constructor parameter declares the field and assigns it.
export class SolarPlant {
constructor(
readonly id: string, readonly name: string, readonly capacityKwp: number,
) {
if (id.trim() === "") throw new Error("A plant needs an id");
if (!Number.isFinite(capacityKwp) || capacityKwp <= 0) {
throw new RangeError(`capacityKwp must be positive, got ${capacityKwp}`);
}
}
describe(): string { return `${this.name} β ${this.capacityKwp} kWp`; }
}
Validation in the constructor makes a valid object the only kind that can exist. Parameter properties help when the constructor is validation plus assignment, and hurt past five or six parameters: the public surface is visible only in the constructor signature, where callers must recall argument order.
Static members, factories and initialisation order
A static member belongs to the class, not to instances: a constant, a counter, a factory.
export class SolarPlant {
static readonly MIN_KWP = 0.1; // one per class, reachable as SolarPlant.MIN_KWP
static fromCsvRow(row: string): SolarPlant {
const [id, name, capacityKwp] = row.split(",");
return new SolarPlant(id, name, Number(capacityKwp));
}
constructor(
readonly id: string, readonly name: string, readonly capacityKwp: number,
) {
if (capacityKwp < SolarPlant.MIN_KWP) throw new RangeError(`below ${SolarPlant.MIN_KWP}`);
}
}
A static factory is the right home for parsing: it keeps the conversion out of the data class and
gives the caller a verb β SolarPlant.fromCsvRow(line). The order of initialisation, precisely:
- Static fields and static blocks run once, in declaration order, when the module is first evaluated β before any instance exists.
- Instance field initialisers run per
new, in declaration order, before the constructor body; in a derived class they run right aftersuper()returns, as Lesson 15 covers.
Methods, arrow-function fields and this
this is decided by how a function is called, not where it is written β except for arrow
functions, which capture this from their definition site.
export class YieldReporter {
constructor(private readonly plants: readonly SolarPlant[]) {}
totalKwp(): number { // prototype method: `this` comes from the call site
return this.plants.reduce((sum, plant) => sum + plant.capacityKwp, 0);
}
readonly totalKwpBound = (): number => // arrow field: `this` is always this instance
this.plants.reduce((sum, plant) => sum + plant.capacityKwp, 0);
}
const reporter = new YieldReporter([plant]); // any SolarPlant instance
reporter.totalKwp(); // β
3.2
[reporter].map(reporter.totalKwpBound); // β
already bound to the instance
[reporter].map(reporter.totalKwp); // β TypeError: Cannot read properties of undefined
setTimeout(reporter.totalKwpBound, 0); // β
a timer callback with the right receiver
The failing line is the classic bug: passing a method as a value loses its receiver, because map
calls it with this set to undefined, and the method then reads this.plants. bind is the
explicit fix when you cannot change the declaration:
const f = reporter.totalKwp.bind(reporter). Use an arrow field for methods handed out as callbacks
β timers, event handlers β and a plain method everywhere else; an arrow field costs one closure per
instance and cannot be overridden by a subclass.
Immutability, and a value object that copies
readonly is enforced inside the class body only: it stops reassignment after initialisation, not a
caller mutating what you handed out.
export class YieldSeries {
private readonly readings: readonly DailyYield[];
constructor(readings: readonly DailyYield[]) {
this.readings = [...readings]; // defensive copy: the caller's array cannot reach us later
}
withReading(reading: DailyYield): YieldSeries {
return new YieldSeries([...this.readings, reading]); // a new instance, never a mutation
}
toArray(): readonly DailyYield[] { return [...this.readings]; }
}
Two habits make a class trustworthy: copy what you receive, copy what you return, and offer withβ¦
methods that return a new instance instead of changing this one. Meters at the top of the lesson is
the same idea in miniature β a small, validated, immutable value object is the most useful shape a
class takes in a domain model.
Common mistakes
| Mistake | What happens | The fix |
|---|---|---|
| Passing a method where a callback is expected | TypeError: Cannot read properties of undefined, because this is undefined at the call |
Use an arrow-function field, wrap the call, or bind it explicitly |
Treating readonly as a runtime guarantee |
Annotated fields can still be changed through Object.assign or a cast |
Treat readonly as design intent, and copy values in and out of the class |
| Storing the array a caller passed in | A later mutation elsewhere changes the object's contents behind its back | Store [...input] in the constructor and return a copy from the accessor |
| Using a class as a bag of public fields | Validation is bypassed with a cast and every invariant leaks to callers | Validate in the constructor, keep fields readonly, expose behaviour |
| Reaching for parameter properties in a six-argument constructor | The public surface becomes invisible and callers depend on argument order | Keep them for short constructors, or take one options object |
| Expecting an instance field to exist before the constructor body | A field without an initialiser is undefined, and strict refuses to read it |
Initialise every field, in the declaration or in the constructor |
Try it
-
Create
src/domain/solarPlant.tswith theSolarPlantclass from this lesson: threereadonlyfields, validation in the constructor, adescribe()method and the static factoryfromCsvRow. -
Create
src/index.ts:
import { SolarPlant } from "./domain/solarPlant.js";
const plant = SolarPlant.fromCsvRow("sn-01,Saint-Louis rooftop,3.2");
console.log(plant.describe());
- Build and run it:
Expected output: Saint-Louis rooftop β 3.2 kWp, and nothing else.
- Break it twice. Pass
ninewhere the capacity belongs and run again: the constructor throws, Node printsRangeError: below 0.1and exits with status 1 (echo $?confirms). Then, with a valid plant, addconsole.log([plant].map(plant.describe));and watch thethistrap from this lesson appear asTypeError: Cannot read properties of undefined (reading 'name').
Key takeaways
- A class is a constructor function plus a prototype: fields are per instance, methods are shared.
- A validating constructor makes invalid values unrepresentable;
strictrefuses unassigned reads. thiscomes from the call site, so hand out arrow fields as callbacks and keep plain methods.- Immutability means
readonlyfields, defensive copies in and out, andwithβ¦methods.
Check yourself
- Where do methods live at runtime, and why does an instance not carry its own copy of each one?
- Why is a class that throws on bad input safer than an interface with the same fields, even though neither can stop a cast?
- In what order do a static field, an instance field initialiser and the constructor body run for the first instance of a class?
- Why does
YieldSeriescopy the array in the constructor and intoArray()β what breaks if you do only one of the two?
