Skip to content

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.


Anatomy of a well-built class with private fields, validation in the constructor and a factory method, plus the inheritance-versus-composition decision

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:

  1. Static fields and static blocks run once, in declaration order, when the module is first evaluated β€” before any instance exists.
  2. Instance field initialisers run per new, in declaration order, before the constructor body; in a derived class they run right after super() 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

  1. Create src/domain/solarPlant.ts with the SolarPlant class from this lesson: three readonly fields, validation in the constructor, a describe() method and the static factory fromCsvRow.

  2. 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());
  1. Build and run it:
cd ~/dev/ts-zero-to-hero
npx tsc
node dist/index.js

Expected output: Saint-Louis rooftop β€” 3.2 kWp, and nothing else.

  1. Break it twice. Pass nine where the capacity belongs and run again: the constructor throws, Node prints RangeError: below 0.1 and exits with status 1 (echo $? confirms). Then, with a valid plant, add console.log([plant].map(plant.describe)); and watch the this trap from this lesson appear as TypeError: 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; strict refuses unassigned reads.
  • this comes from the call site, so hand out arrow fields as callbacks and keep plain methods.
  • Immutability means readonly fields, defensive copies in and out, and with… methods.

Check yourself

  1. Where do methods live at runtime, and why does an instance not carry its own copy of each one?
  2. Why is a class that throws on bad input safer than an interface with the same fields, even though neither can stop a cast?
  3. In what order do a static field, an instance field initialiser and the constructor body run for the first instance of a class?
  4. Why does YieldSeries copy the array in the constructor and in toArray() β€” what breaks if you do only one of the two?

Next

Lesson 14 β€” Access Modifiers, Encapsulation and Getters