Learn / TypeScript
LESSON 38 OF 84

TypeScript Foundation

Classes in TypeScript

Declare typed class properties, use parameter properties and access modifiers, compare private with #private, write abstract classes and classes that implement interfaces, and wire classes together with constructor injection.

  • 40 min to read and try
  • You need: Generics, and the JavaScript classes lesson
  • You build: A TaskService that depends on interfaces, wired by hand in main.ts and tested with a fake repository and a fixed clock
Test yourself

Properties and constructors

You learned classes in the JavaScript classes lesson. In JavaScript, a constructor can create any property just by assigning this.title = …. TypeScript wants to know every property up front, with its type, so it can check every use. You declare them at the top of the class:

task.ts
export class Task {
  done = false;
  tags: string[] = [];
  id: number;
  title: string;

  constructor(id: number, title: string) {
    this.id = id;
    this.title = title;
  }

  complete(): this {
    this.done = true;
    return this;
  }

  describe(): string {
    return `${this.done ? "[x]" : "[ ]"} #${this.id} ${this.title}`;
  }
}

const milk = new Task(1, "Buy milk").complete();
console.log(milk.describe());
console.log(milk);
Output of npx tsx task.ts and of the browser terminal
[x] #1 Buy milk
Task { done: true, tags: [], id: 1, title: 'Buy milk' }

done = false declares the property and gives it a starting value, so TypeScript infers boolean. complete() returns this, the object itself, so calls can be chained. Now the two mistakes this catches:

task.ts
export class Task {
  id: number;
  done: boolean;

  constructor(id: number, title: string) {
    this.id = id;
    this.title = title;
  }
}
What npx tsc --noEmit prints
task.ts:3:3 - error TS2564: Property 'done' has no initializer and is not definitely assigned in the constructor.

3   done: boolean;
    ~~~~

task.ts:7:10 - error TS2339: Property 'title' does not exist on type 'Task'.

7     this.title = title;
           ~~~~~


Found 2 errors in the same file, starting at: task.ts:3
  • TS2564: done is declared as a boolean, but nothing ever sets it, so it would be undefined. strict checks that every property gets a value.
  • TS2339: title was never declared. In JavaScript this line silently adds a property; in TypeScript a typo like this.titel = … would be caught the same way.

Parameter properties

Declaring a property, taking a parameter, and copying one into the other is so common that TypeScript has a shortcut. Put public, private, protected or readonly in front of a constructor parameter, and it becomes a property automatically. These are called parameter properties:

short.ts
class Task {
  constructor(
    public readonly id: number,
    public title: string,
    public done = false,
  ) {}
}

const task = new Task(1, "Buy milk");
task.title = "Buy oat milk";
console.log(task);
Output of npx tsx short.ts and of the browser terminal
Task { id: 1, title: 'Buy oat milk', done: false }

Three properties, declared and filled in far fewer lines. public done = false also shows that a parameter property can have a default value. readonly means the id is set once in the constructor and can never change; task.id = 2 would be error TS2540.

NOT ERASABLE

Parameter properties generate code (the this.id = id lines), so Node's built-in type stripping, from Why TypeScript exists, refuses them. tsx, tsc and the browser terminal all handle them. ZudoJS itself uses them, so this course runs code with tsx.

public, private, protected and #private

TypeScript has three access modifiers that say who may use a property or method:

  • public (the default): anyone.
  • private: only code inside this class.
  • protected: code inside this class and classes that extends it.
access.ts
class Account {
  protected balance = 0;
  private readonly history: number[] = [];

  deposit(amount: number): void {
    this.balance += amount;
    this.history.push(amount);
  }
}

class SavingsAccount extends Account {
  addInterest(rate: number): void {
    this.balance += this.balance * rate;
    this.history.push(-1);
  }
}

const account = new SavingsAccount();
account.deposit(100);
console.log(account.balance);
What npx tsc --noEmit prints
access.ts:14:10 - error TS2341: Property 'history' is private and only accessible within class 'Account'.

14     this.history.push(-1);
            ~~~~~~~

access.ts:20:21 - error TS2445: Property 'balance' is protected and only accessible within class 'Account' and its subclasses.

20 console.log(account.balance);
                       ~~~~~~~


Found 2 errors in the same file, starting at: access.ts:14

The subclass may use balance (protected) but not history (private), and outside code may use neither.

private is a compile-time check; #private is real

You met JavaScript's own private fields, written #name, in the classes lesson. They look similar to private, but there is an important difference. TypeScript's private is a type, and like all types it is erased. When the program runs, the property is an ordinary, public property. A # field is enforced by JavaScript itself:

secret.ts
class UserA {
  constructor(
    public email: string,
    private passwordHash: string,
  ) {}
}

class UserB {
  #passwordHash: string;

  constructor(public email: string, passwordHash: string) {
    this.#passwordHash = passwordHash;
  }

  hasHash(): boolean {
    return this.#passwordHash.length > 0;
  }
}

const a = new UserA("ada@example.com", "$argon2id$v=19$...");
const b = new UserB("ada@example.com", "$argon2id$v=19$...");

console.log(JSON.stringify(a));
console.log(JSON.stringify(b));
console.log(Object.keys(a), Object.keys(b));
console.log(a["passwordHash"]);
Output of npx tsx secret.ts and of the browser terminal
{"email":"ada@example.com","passwordHash":"$argon2id$v=19$..."}
{"email":"ada@example.com"}
[ 'email', 'passwordHash' ] [ 'email' ]
$argon2id$v=19$...

This file compiles without errors, and look at the first line: the private password hash went straight into the JSON. If this object were an API response, every client would receive the hash. TypeScript even allows a["passwordHash"] with square brackets, as a deliberate escape hatch.

The #passwordHash in UserB is not in the JSON, not in Object.keys, and cannot be read from outside at all. The rule:

  • Use private to organise your own code: "this is an internal detail, do not call it".
  • Use #private for anything that must really stay hidden, such as secrets. And better still, never put secrets in objects you might send; build the response with only the fields you choose, as in the public user exercise.

Abstract classes

An abstract class is a class that is only a starting point. You cannot create one with new. It can contain finished methods, and abstract methods: a name and a type, with no body, that every subclass must write.

notifier.ts
abstract class Notifier {
  constructor(protected readonly appName: string) {}

  protected abstract send(to: string, text: string): Promise<void>;

  async taskDue(to: string, title: string): Promise<void> {
    await this.send(to, `[${this.appName}] "${title}" is due today`);
  }
}

class ConsoleNotifier extends Notifier {
  protected async send(to: string, text: string): Promise<void> {
    console.log(`to ${to}: ${text}`);
  }
}

const notifier = new ConsoleNotifier("Tasks");
await notifier.taskDue("ada@example.com", "File taxes");
Output of npx tsx notifier.ts and of the browser terminal
to ada@example.com: [Tasks] "File taxes" is due today

Notifier decides what the message says; each subclass decides how it is delivered: console, e-mail, SMS. The compiler holds both sides to the deal:

notifier.ts
abstract class Notifier {
  protected abstract send(to: string, text: string): Promise<void>;
}

class EmailNotifier extends Notifier {}

const notifier = new Notifier();
What npx tsc --noEmit prints
notifier.ts:5:7 - error TS2515: Non-abstract class 'EmailNotifier' does not implement inherited abstract member send from class 'Notifier'.

5 class EmailNotifier extends Notifier {}
        ~~~~~~~~~~~~~

notifier.ts:7:18 - error TS2511: Cannot create an instance of an abstract class.

7 const notifier = new Notifier();
                   ~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: notifier.ts:5

Implementing interfaces

An abstract class shares code. Often you only need to share a contract: "anything that stores tasks must have these methods". That is an interface, and a class promises to follow it with implements. Here is the start of a small multi-file project. First the contracts:

contracts.ts
export interface Task {
  readonly id: number;
  title: string;
  dueDate: Date;
  done: boolean;
}

export interface TaskRepository {
  save(task: Task): Promise<void>;
  findAll(): Promise<Task[]>;
}

export interface Clock {
  now(): Date;
}

Then a class that implements the repository contract, keeping tasks in memory:

memory-repository.ts
import type { Task, TaskRepository } from "./contracts.js";

export class MemoryTaskRepository implements TaskRepository {
  readonly #tasks = new Map<number, Task>();

  async save(task: Task): Promise<void> {
    this.#tasks.set(task.id, { ...task });
  }

  async findAll(): Promise<Task[]> {
    return [...this.#tasks.values()].map((task) => ({ ...task }));
  }
}

implements TaskRepository makes the compiler check the class against the interface. Forget a method, or get a type wrong, and the class itself is reported:

broken-repository.ts
import type { Task, TaskRepository } from "./contracts.js";

export class BrokenRepository implements TaskRepository {
  async save(task: Task): Promise<void> {}
}
What npx tsc --noEmit prints
broken-repository.ts:3:14 - error TS2420: Class 'BrokenRepository' incorrectly implements interface 'TaskRepository'.
  Property 'findAll' is missing in type 'BrokenRepository' but required in type 'TaskRepository'.

3 export class BrokenRepository implements TaskRepository {
               ~~~~~~~~~~~~~~~~

  contracts.ts:10:3 - 'findAll' is declared here.
    10   findAll(): Promise<Task[]>;
         ~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 1 error in broken-repository.ts:3

A class can implement several interfaces (implements A, B), and implements adds no code at all: it is only a check.

Dependency injection by hand

Now the service with the business rules. It needs somewhere to store tasks and a way to know the current time. It could create them itself: new MemoryTaskRepository() and new Date(). But then it is stuck with them forever: no database later, and no way to test "is this task overdue?" without waiting for a real day to pass.

Instead, the service asks for what it needs, in its constructor, typed as the interfaces. Whoever creates the service passes them in. This is called dependency injection, and passing them to the constructor is constructor injection:

task-service.ts
import type { Clock, Task, TaskRepository } from "./contracts.js";

export class TaskService {
  #nextId = 1;

  constructor(
    private readonly tasks: TaskRepository,
    private readonly clock: Clock,
  ) {}

  async add(title: string, daysUntilDue: number): Promise<Task> {
    const dueDate = new Date(this.clock.now().getTime() + daysUntilDue * 86_400_000);
    const task: Task = { id: this.#nextId++, title, dueDate, done: false };
    await this.tasks.save(task);
    return task;
  }

  async overdue(): Promise<string[]> {
    const now = this.clock.now();
    const all = await this.tasks.findAll();
    return all.filter((task) => !task.done && task.dueDate < now).map((task) => task.title);
  }
}

TaskService never mentions MemoryTaskRepository or Date.now(). It depends only on the two interfaces. The real objects are created in one place, the program's entry file, often called the composition root:

main.ts
import type { Clock } from "./contracts.js";
import { MemoryTaskRepository } from "./memory-repository.js";
import { TaskService } from "./task-service.js";

const systemClock: Clock = { now: () => new Date() };
const service = new TaskService(new MemoryTaskRepository(), systemClock);

await service.add("File taxes", 3);
await service.add("Call Ada", -1);
console.log(await service.overdue());
Output of npx tsx main.ts and of the browser terminal
[ 'Call Ada' ]

A task due yesterday (-1 days) is overdue; one due in three days is not. systemClock is a plain object, not a class: anything with a now() method that returns a Date fits the Clock interface.

Swapping in fakes for tests

Here is the payoff. To test overdue(), you want time to stand still, and you want to see exactly what the service saved. So you write two fakes: simple stand-ins that implement the same interfaces.

fakes.ts
import type { Clock, Task, TaskRepository } from "./contracts.js";

export class FixedClock implements Clock {
  constructor(private current: Date) {}

  now(): Date {
    return this.current;
  }

  advanceDays(days: number): void {
    this.current = new Date(this.current.getTime() + days * 86_400_000);
  }
}

export class RecordingRepository implements TaskRepository {
  readonly saved: Task[] = [];

  async save(task: Task): Promise<void> {
    this.saved.push(task);
  }

  async findAll(): Promise<Task[]> {
    return this.saved;
  }
}
overdue-check.ts
import { FixedClock, RecordingRepository } from "./fakes.js";
import { TaskService } from "./task-service.js";

const clock = new FixedClock(new Date("2026-03-01T09:00:00Z"));
const repo = new RecordingRepository();
const service = new TaskService(repo, clock);

await service.add("File taxes", 2);
console.log("saved:", repo.saved.map((task) => task.dueDate.toISOString()));
console.log("day 0:", await service.overdue());
clock.advanceDays(3);
console.log("day 3:", await service.overdue());
Output of npx tsx overdue-check.ts and of the browser terminal
saved: [ '2026-03-03T09:00:00.000Z' ]
day 0: []
day 3: [ 'File taxes' ]

Three days pass in a microsecond, and every run prints the same thing. TaskService did not change at all: it cannot tell a fake from the real thing, because it only knows the interfaces. With the Vitest setup from the testing lesson, the same idea becomes a test file:

task-service.test.ts
import { describe, expect, it } from "vitest";

import { FixedClock, RecordingRepository } from "./fakes.js";
import { TaskService } from "./task-service.js";

describe("TaskService.overdue", () => {
  it("reports a task only after its due date", async () => {
    const clock = new FixedClock(new Date("2026-03-01T09:00:00Z"));
    const service = new TaskService(new RecordingRepository(), clock);
    await service.add("File taxes", 2);

    expect(await service.overdue()).toEqual([]);
    clock.advanceDays(3);
    expect(await service.overdue()).toEqual(["File taxes"]);
  });
});
Terminal on your computer
npm install -D vitest

npx vitest run --reporter=verbose
 RUN  v5.0.1 ~/ts-tasks

 ✓ task-service.test.ts > TaskService.overdue > reports a task only after its due date 4ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  14:46:55
   Duration  326ms (transform 74%, import 16%, tests 5%, worker 5%)

Doing this wiring by hand is fine for three classes. A real backend has dozens of services, each needing several others, and creating them all in the right order in main.ts gets long. A dependency injection container does that wiring for you. ZudoJS has one, @zudojs/container, and you will meet it in the container lesson. It works with exactly the kind of classes you just wrote.

Practice

TRY IT YOURSELF

A counter with real privacy

Write a class RateCounter that counts login attempts per e-mail address. It has hit(email: string): number (adds one and returns the new count) and reset(email: string): void. The counts must not show up in JSON.stringify or be changeable from outside.

Show a solution
rate-counter.ts
class RateCounter {
  readonly #counts = new Map<string, number>();

  hit(email: string): number {
    const next = (this.#counts.get(email) ?? 0) + 1;
    this.#counts.set(email, next);
    return next;
  }

  reset(email: string): void {
    this.#counts.delete(email);
  }
}

const counter = new RateCounter();
counter.hit("ada@example.com");
console.log(counter.hit("ada@example.com"), JSON.stringify(counter));
counter.reset("ada@example.com");
console.log(counter.hit("ada@example.com"));
Output of npx tsx rate-counter.ts and of the browser terminal
2 {}
1

TRY IT YOURSELF

Inject the notifier

Give TaskService a third dependency: a Notifier interface with send(text: string): Promise<void>. Add a method remindOverdue() that sends one message per overdue task. Test it with a fake notifier that stores the messages in an array.

Show a solution
remind.ts
interface Notifier {
  send(text: string): Promise<void>;
}

class ReminderService {
  constructor(
    private readonly overdueTitles: () => Promise<string[]>,
    private readonly notifier: Notifier,
  ) {}

  async remindOverdue(): Promise<number> {
    const titles = await this.overdueTitles();
    for (const title of titles) await this.notifier.send(`"${title}" is overdue`);
    return titles.length;
  }
}

class FakeNotifier implements Notifier {
  readonly sent: string[] = [];
  async send(text: string): Promise<void> {
    this.sent.push(text);
  }
}

const fake = new FakeNotifier();
const reminders = new ReminderService(async () => ["File taxes", "Call Ada"], fake);
console.log(await reminders.remindOverdue(), fake.sent);
Output of npx tsx remind.ts and of the browser terminal
2 [ '"File taxes" is overdue', '"Call Ada" is overdue' ]

This solution goes one step further: instead of the whole TaskService, the reminder service depends only on a function that returns overdue titles. Depending on the smallest thing you need makes testing even easier. A dependency can be a function type, not just an interface.

Recap

  • Declare every class property with its type; strict checks each one gets a value.
  • Parameter properties (constructor(private readonly repo: Repo)) declare and assign in one step.
  • public, protected and private are compile-time only. #private is enforced at runtime and stays out of JSON.stringify.
  • An abstract class shares code and forces subclasses to fill in abstract methods. implements checks a class against an interface.
  • Constructor injection: depend on interfaces, create the real objects in one place, and swap in fakes for tests.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.