Foundations Updated 2026-09 View as Markdown

Classes and Access Modifiers

TypeScript adds real access control, parameter properties and interface implementation to JavaScript classes.

JavaScript classes work in TypeScript unchanged. What TypeScript adds is access control the compiler enforces, and the ability to declare that a class satisfies an interface.

typescript
class Account {
  readonly id: string;
  private balanceCents: number;
  protected currency: string;

  constructor(id: string, openingCents: number, currency = "USD") {
    this.id = id;
    this.balanceCents = openingCents;
    this.currency = currency;
  }

  deposit(cents: number): void {
    if (cents <= 0) throw new RangeError("deposit must be positive");
    this.balanceCents += cents;
  }

  get balance(): number {
    return this.balanceCents / 100;
  }
}
typescript
const a = new Account("acc_1", 10_000);
a.deposit(500);
a.balance;             // 105
a.balanceCents;        // error: 'balanceCents' is private
a.id = "acc_2";        // error: 'id' is readonly

The modifiers#

ModifierVisible from
public (default)anywhere
protectedthe class and its subclasses
privateonly inside the class
readonlyanywhere, but cannot be reassigned after construction
staticon the class itself, not instances

Parameter properties#

The constructor above is mostly boilerplate. TypeScript can declare and assign in one step:

typescript
class Account {
  constructor(
    public readonly id: string,
    private balanceCents: number,
    protected currency: string = "USD",
  ) {}
}

Identical behaviour, far less repetition. A modifier on a constructor parameter creates the property and assigns it.

Two caveats: this is TypeScript-only syntax, so it is not erasable — it will not work under a runtime type-stripper or with erasableSyntaxOnly. And it is the mechanism most dependency-injection frameworks rely on, which is why NestJS controllers look the way they do.

implements#

implements states that a class satisfies an interface, and the compiler checks it:

typescript
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  save(item: T): Promise<void>;
}

class UserRepo implements Repository<User> {
  async findById(id: string): Promise<User | null> { /* … */ }
  async save(user: User): Promise<void> { /* … */ }
  // omit one and you get an error naming exactly what is missing
}

implements is a check, not inheritance — it adds nothing at runtime and the class must still write every member itself. Because TypeScript is structurally typed, a class with the right shape satisfies the interface whether or not it says implements; the keyword just moves the error to the class rather than to its first use.

Abstract classes#

A base that cannot be instantiated and can require subclasses to fill in the gaps:

typescript
abstract class Shape {
  abstract area(): number;                 // subclasses must implement

  describe(): string {                     // shared implementation
    return `${this.constructor.name} with area ${this.area().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(private radius: number) { super(); }
  area(): number { return Math.PI * this.radius ** 2; }
}

new Shape();          // error: cannot create an instance of an abstract class
new Circle(2).describe();   // "Circle with area 12.57"

When not to use a class#

Classes are a tool, not a default. Reach for one when you have state and behaviour that belong together, and something that genuinely benefits from instances.

A class with no state, or one that is only ever instantiated once, is a namespace with extra steps:

typescript
// no state — a class adds nothing
class MathUtils {
  static add(a: number, b: number) { return a + b; }
}

// just export the functions
export function add(a: number, b: number) { return a + b; }

Plain functions over plain data are easier to test, easier to tree-shake, and avoid this entirely — which removes a whole class of binding bug. Use classes where they earn it: entities with invariants, stateful services, and anywhere a framework expects them.

Exercise#

typescript
// Model a `Playlist`:
//   - readonly id, private tracks array
//   - add(track) rejecting duplicates by id
//   - a `duration` getter returning total seconds
//   - implements an interface `Sized { readonly size: number }`
// Use parameter properties for the constructor.

// write your code here

Common questions#

private or #?#

# when you want the field genuinely unreachable at runtime, private when you only want the compiler to stop your own code touching it. # is real JavaScript and survives compilation; private is erased.

Should I use implements on every class?#

Only where an interface is a meaningful contract — something with more than one implementation, or a boundary you want stated explicitly. Structural typing means a matching class works either way; implements mainly improves where the error appears when the class drifts.

Are parameter properties safe to use?#

They are convenient and widely used, especially with dependency injection. Be aware they are TypeScript-only syntax, so they break under erasableSyntaxOnly and runtime type-stripping. If you need your .ts files to run without a compile step, write the assignments out.

Get the TypeScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for TypeScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.