Skip to lesson

learningtypescript.org / intermediate / 18-classes-and-oop · lesson 9 of 25

TL;DR

Learn TypeScript classes and OOP with constructors, inheritance, access modifiers, and interfaces for type-safe object-oriented code.

Key concepts

  • TypeScript classes
  • TypeScript OOP
  • TypeScript inheritance
  • access modifiers TypeScript

Classes And OOP

JavaScript has always had objects, but TypeScript takes object-oriented programming (OOP) to a new level by adding access modifiers, strong typing on class members, and first-class interface support. Classes let you bundle data and behavior together into reusable blueprints — and TypeScript ensures you use them correctly at compile time.

What You'll Learn

By the end of this lesson you will build an ActivityStore — a class holding its records in a private Map, exposing add, get, all and a computed size, with nothing outside able to reach the state directly. Along the way you will be able to say exactly which of TypeScript's class features survive into the running program and which are erased before it starts.

This lesson feeds the tracker's storage layer, and it is deliberately marked as not required for the final project: a class is one valid shape for that service, and a factory function over the same Map would do the job equally well. Treat what follows as a shape worth knowing rather than the shape the capstone demands — the load-bearing version arrives in Generics, where the same store is written once for any record type. You arrive here able to describe an object's shape with an interface and to narrow a union to one member; what is new is bundling that data together with the behaviour that operates on it, and controlling who may touch it.

Defining a Class

A class is a template for creating objects. It defines properties (data) and methods (behavior) that every instance will have.

class Product {
  name: string;
  price: number;
  inStock: boolean;

  constructor(name: string, price: number, inStock = true) {
    this.name = name;
    this.price = price;
    this.inStock = inStock;
  }

  describe(): string {
    const availability = this.inStock ? "In stock" : "Out of stock";
    return `${this.name} — $${this.price.toFixed(2)} (${availability})`;
  }
}

const shirt = new Product("Merino Wool Shirt", 79.99);
const soldOut = new Product("Limited Hoodie", 129.99, false);

console.log(shirt.describe());   // "Merino Wool Shirt — $79.99 (In stock)"
console.log(soldOut.describe()); // "Limited Hoodie — $129.99 (Out of stock)"

The constructor runs once when you call new Product(...). Properties declared outside the constructor must be initialized either inline or inside it — TypeScript enforces this with strict property initialization checks.

Access Modifiers

TypeScript adds three access modifiers that control where class members can be read or written:

  • public — accessible everywhere (the default)
  • private — accessible only inside the class itself
  • protected — accessible inside the class and its subclasses
class BankAccount {
  public owner: string;
  private balance: number;

  constructor(owner: string, initialBalance: number) {
    this.owner = owner;
    this.balance = initialBalance;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new Error("Deposit must be positive");
    this.balance += amount;
  }

  withdraw(amount: number): void {
    if (amount > this.balance) throw new Error("Insufficient funds");
    this.balance -= amount;
  }

  getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount("Alice", 1000);
account.deposit(500);
account.withdraw(200);

console.log(`${account.owner}'s balance: $${account.getBalance()}`); // "$1300"

// account.balance = 99999; // Error: 'balance' is private

Making balance private means external code can only interact with it through deposit, withdraw, and getBalance — this is the OOP principle of encapsulation.

What private actually promises (a no-run demo)

The commented-out assignment above is the point of the modifier, but a comment cannot show you the error. This fence is marked no-run because its whole job is to fail to compile: the runner strips the types and would perform the assignment happily, which — as you are about to see — is more than a technicality here.

class BankAccount {
  public owner: string;
  private balance: number;

  constructor(owner: string, initialBalance: number) {
    this.owner = owner;
    this.balance = initialBalance;
  }

  getBalance(): number {
    return this.balance;
  }
}

const account = new BankAccount("Alice", 1000);
account.balance = 99999;

console.log(account.getBalance());

Under tsc --strict this fails with:

error TS2341: Property 'balance' is private and only accessible within class 'BankAccount'.

Now the runtime shadow, and this one is worth dwelling on. private is a TypeScript modifier: it is erased along with the types, so nothing stops the write once the program is running. JavaScript's own # private fields are a different mechanism — they are emitted, and the engine enforces them:

class BankAccount {
  public owner: string;
  private balance: number;

  constructor(owner: string, initialBalance: number) {
    this.owner = owner;
    this.balance = initialBalance;
  }

  getBalance(): number {
    return this.balance;
  }
}

class SafeAccount {
  #balance: number;

  constructor(initialBalance: number) {
    this.#balance = initialBalance;
  }

  getBalance(): number {
    return this.#balance;
  }
}

const account = new BankAccount("Alice", 1000);
// The runner strips the private modifier, so this assignment simply happens.
(account as unknown as { balance: number }).balance = 99999;
console.log("private balance after outside write:", account.getBalance());

const safe = new SafeAccount(1000);
(safe as unknown as Record<string, unknown>)["#balance"] = 99999;
console.log("#balance after outside write:", safe.getBalance());

That prints private balance after outside write: 99999 and then #balance after outside write: 1000. The private field was overwritten from outside; the # field was not — the bracket write created an unrelated property named "#balance" and the real one was untouched. So private is a design tool that keeps your own code honest at compile time, and # is a runtime guarantee. Reach for private for encapsulation you and your teammates agree to; reach for # when the value must stay protected even from code the compiler never saw.

Constructor Shorthand

TypeScript lets you skip the property declarations and this.x = x assignments by adding access modifiers directly to constructor parameters:

class Point {
  constructor(
    public x: number,
    public y: number
  ) {}

  distanceTo(other: Point): number {
    const dx = this.x - other.x;
    const dy = this.y - other.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  toString(): string {
    return `(${this.x}, ${this.y})`;
  }
}

const start = new Point(0, 0);
const target = new Point(3, 4);

console.log(`Distance from ${start} to ${target}: ${start.distanceTo(target)}`);
// "Distance from (0, 0) to (3, 4): 5"

This shorthand is idiomatic TypeScript — you'll see it everywhere in real codebases.

Inheritance

One class can extend another to inherit its properties and methods. The child class can override parent methods to specialize behavior.

class Animal {
  constructor(public name: string) {}

  speak(): string {
    return `${this.name} makes a sound.`;
  }

  toString(): string {
    return this.name;
  }
}

class Dog extends Animal {
  constructor(name: string, public breed: string) {
    super(name); // must call super before using 'this'
  }

  speak(): string {
    return `${this.name} barks!`;
  }

  fetch(item: string): string {
    return `${this.name} fetches the ${item}.`;
  }
}

class Cat extends Animal {
  speak(): string {
    return `${this.name} meows.`;
  }
}

const dog = new Dog("Rex", "Labrador");
const cat = new Cat("Whiskers");

console.log(dog.speak());         // "Rex barks!"
console.log(dog.fetch("ball"));   // "Rex fetches the ball."
console.log(dog.breed);           // "Labrador"
console.log(cat.speak());         // "Whiskers meows."

The super() call in the child constructor passes arguments up to the parent. When overriding a method, you can also call super.speak() to invoke the parent's version if you need it.

Predict

The parent defines label, which calls this.name(). The child overrides name but NOT label. Trace c.label() by hand — which name runs, the parent's or the child's? Predict the logged line before running.

class Shape {
name(): string {
  return "shape";
}
label(): string {
  return "This is a " + this.name();
}
}

class Circle extends Shape {
name(): string {
  return "circle";
}
}

const c = new Circle();
console.log(c.label());
Continue learning

Implementing Interfaces

Classes can implement one or more interfaces, which act as contracts that guarantee certain properties and methods exist.

interface Serializable {
  serialize(): string;
}

interface Validatable {
  isValid(): boolean;
}

class EmailAddress implements Serializable, Validatable {
  constructor(private address: string) {}

  isValid(): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.address);
  }

  serialize(): string {
    return JSON.stringify({ email: this.address });
  }

  toString(): string {
    return this.address;
  }
}

const valid = new EmailAddress("user@example.com");
const invalid = new EmailAddress("not-an-email");

console.log(valid.isValid());    // true
console.log(invalid.isValid());  // false
console.log(valid.serialize());  // '{"email":"user@example.com"}'

The implements keyword does not add any runtime behavior — it only tells TypeScript to check that your class satisfies the interface shape. If you forget a required method, TypeScript reports an error at the class definition, not at the call site.

Recall

Without scrolling up: a class can implements Serializable. Back in Interfaces and Types you already used Serializable as an interface without any class — as one of the base interfaces a document interface extended, so a plain object had to have its shape. What is the single interface doing in each case, and how does that fit one definition serving both?

Continue learning

Getters and Setters

TypeScript supports get and set accessors, which look like property access but run a function behind the scenes.

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number {
    return this._celsius;
  }

  set celsius(value: number) {
    if (value < -273.15) throw new Error("Below absolute zero");
    this._celsius = value;
  }

  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }

  get kelvin(): number {
    return this._celsius + 273.15;
  }
}

const temp = new Temperature(100);
console.log(`${temp.celsius}°C = ${temp.fahrenheit}°F = ${temp.kelvin}K`);
// "100°C = 212°F = 373.15K"

temp.celsius = 0;
console.log(`${temp.celsius}°C = ${temp.fahrenheit}°F`);
// "0°C = 32°F"

Getters and setters let you add validation or computed properties while keeping a clean property-style API.

Arrange the code

Reassemble a program that defines a chainable Wallet class, creates one with a starting balance of 100, funds it by 50, spends 30, then logs the final total. The lines are shuffled. Put them back in an order that runs, then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?

  1. class Wallet { constructor(private balance: number) {} add(n: number): this { this.balance += n; return this; } spend(n: number): this { this.balance -= n; return this; } total(): number { return this.balance; } }
  2. console.log(spent.total());
  3. const wallet = new Wallet(100);
  4. const spent = funded.spend(30);
  5. const funded = wallet.add(50);
Continue learning

A Worked Example: Shape Hierarchy

Here is a small shape hierarchy built on an abstract base. Each shape knows its area and perimeter and can describe itself; the abstract base guarantees every subclass implements area and perimeter. Read Circle and Rectangle — they are worked in full — then finish Square, which is left as a TODO. The fence runs either way, so the wrong answer shows up in the numbers rather than in an error.

abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;

  describe(): string {
    return `${this.constructor.name}: area=${this.area().toFixed(2)}, perimeter=${this.perimeter().toFixed(2)}`;
  }
}

class Circle extends Shape {
  constructor(public radius: number) {
    super();
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }

  perimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

class Rectangle extends Shape {
  constructor(public width: number, public height: number) {
    super();
  }

  area(): number {
    return this.width * this.height;
  }

  perimeter(): number {
    return 2 * (this.width + this.height);
  }
}

// TODO: Circle and Rectangle above are worked in full. Square is the one you
// finish. A square IS a rectangle whose sides are equal, so it needs no area
// or perimeter of its own — only a constructor that passes the single side
// through to Rectangle's two parameters. Replace the 1, 1 below; until you do,
// new Square(3) reports area=1.00, perimeter=4.00 instead of 9.00 and 12.00.
class Square extends Rectangle {
  constructor(side: number) {
    super(1, 1);
  }
}

const shapes: Shape[] = [
  new Circle(5),
  new Rectangle(4, 6),
  new Square(3),
];

for (const shape of shapes) {
  console.log(shape.describe());
}

// Try adding a Triangle class with three sides!

abstract classes cannot be instantiated directly — they exist purely as base classes. Abstract methods must be implemented by every subclass, giving you compile-time enforcement of the contract across the entire hierarchy.

Debug

This renders a label for every entry, and Quiz overrides label so its entries read differently from plain activities. The override is correct and the data is correct, but the program crashes before it prints anything. Commit a hypothesis about what the map is handing to the second map, then fix it so both lines render.

import assert from "node:assert";

class Activity {
constructor(public title: string, public minutesSpent: number) {}

label(): string {
	return `${this.title} (${this.minutesSpent}m)`;
}
}

class Quiz extends Activity {
constructor(title: string, public score: number) {
	super(title, 0);
}

label(): string {
	return `${this.title} (scored ${this.score})`;
}
}

const entries: Activity[] = [
new Activity("Types", 30),
new Quiz("Union quiz", 90),
];

// Render every entry by handing the method itself to map.
const lines = entries.map(entry => entry.label).map(fn => fn());

assert.deepStrictEqual(
lines,
["Types (30m)", "Union quiz (scored 90)"],
"bad report lines: " + JSON.stringify(lines),
);
console.log(lines.join(" | "));

Expected output:

Types (30m) | Union quiz (scored 90)
Continue learning

Try It Yourself

Reading about classes is not the same as building a service with one. This is a build task: a small program that reports its own pass/fail. You are given an Activity entity, and you finish a class-based ActivityStore — a service over a Map, which is one valid shape for the tracker's storage layer. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.

The pieces reuse exactly what this lesson taught: a private field for encapsulated state, methods that return this to chain, a get accessor for a computed property, and reading the store only through its public surface. The starter has the entity, the class shell, and the checks — you write only the logic inside each member.

Build

Finish the build. A class shell and one function are stubbed, and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that member is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// The entity type is given. Do NOT change this.
interface Activity {
id: string;
title: string;
minutesSpent: number;
}

const seed: Activity[] = [
{ id: "a1", title: "Types", minutesSpent: 30 },
{ id: "a2", title: "Narrowing", minutesSpent: 20 },
];

// TODO 1: finish the ActivityStore class — a class-based service over a Map.
//   - add(activity): store it in this.items keyed by id, and return this (to chain)
//   - get(id): return the Activity or undefined  (this.items.get already does this)
//   - all(): return every stored Activity as an array — Array.from(this.items.values())
//   - the size getter: return this.items.size
//   const s = new ActivityStore(); s.add(seed[0]).add(seed[1]); s.size -> 2
class ActivityStore {
private items = new Map<string, Activity>();

add(activity: Activity): this {
	// your code here
	return this; // replace this
}

get(id: string): Activity | undefined {
	// your code here
	return undefined; // replace this
}

all(): Activity[] {
	// your code here
	return []; // replace this
}

get size(): number {
	// your code here
	return 0; // replace this
}
}

// TODO 2: total the minutes across everything currently in a store.
//   Read the store through its public surface only — call store.all(), then sum.
//   totalMinutes(storeWithBothSeeds) -> 50
function totalMinutes(store: ActivityStore): number {
// your code here
return 0; // replace this
}

// --- Build checks: these must all pass. Do not edit below this line. ---
const store = new ActivityStore();
store.add(seed[0]).add(seed[1]);
assert.strictEqual(store.size, 2, "TODO 1: add should store activities and size should report the count");
assert.strictEqual(store.get("a1")?.title, "Types", "TODO 1: get should return the stored activity by id");
assert.strictEqual(totalMinutes(store), 50, "TODO 2: totalMinutes should sum minutesSpent across the store");

console.log("All checks passed.");
console.log("Stored:", store.size);
console.log("First title:", store.get("a1")?.title);
console.log("Total minutes:", totalMinutes(store));

Expected output:

All checks passed.
Stored: 2
First title: Types
Total minutes: 50
Continue learning

Once it passes, try two variations and predict each before running:

  1. Count instead of sum. In totalMinutes, change sum + a.minutesSpent to sum + 1 so it counts activities rather than adding minutes. Predict which check fails first before running. totalMinutes(store) now returns 2 (two stored activities), so TODO 2's check fires with AssertionError: TODO 2: totalMinutes should sum minutesSpent across the store and 2 !== 50 — the TODO 1 checks still pass, so this is the first failure. An instructive assert failure isolating summing a field from counting elements.
  2. Echo every stored title. After the checks pass, add console.log("All titles:", store.all().map((a) => a.title).join(", ")); below the existing logs. Predict the new line before running. all() returns both stored activities in insertion order, so you get All titles: Types, Narrowing. This changes the echoed output, not any check — and shows all() reading the whole store through its public surface.

Capstone milestone

Milestone — the tracker service (one valid shape). The tracker stores its activities behind a service that exposes add / get / all / a count. A class over a private Map — the ActivityStore you just built — is one valid shape for it; a functional factory over the same Map works too. Confirm you can build a class-based service with encapsulated state and a public surface.

Hint: A class is one valid shape for this milestone, not the required one — this lesson is not requiredForFinal. The Generics and Utility Types lessons carry the load-bearing parts. Here you confirm you can build the service as a class; in the capstone the same shape becomes a generic Tracker<T extends { id: string }>.

  • Built a class with a private field holding a Map, so its state is encapsulated
  • Exposed add / get / all through public methods (add returns this to chain)
  • Used a get accessor for a computed, property-style read (size)
  • Read the store only through its public surface — no reaching into the private Map from outside
Continue learning

Transfer

TypeScript's private marks a field off-limits, and the compiler rejects any access from outside the class. JavaScript's own #private fields do something that looks the same in the source. One of these survives into the running program and one does not. Which statement describes what actually differs, rather than a surface resemblance?

Continue learning

Key Takeaways

  • Classes bundle related data and behavior into reusable blueprints; new ClassName() creates an instance
  • Access modifiers (public, private, protected) enforce encapsulation and prevent unintended mutation
  • Constructor shorthand (constructor(public x: number)) eliminates boilerplate property declarations
  • Inheritance (extends) lets subclasses reuse and specialize parent behavior; always call super() first
  • Interfaces used with implements act as contracts, letting you write polymorphic code against a shape rather than a concrete class
  • Getters and setters add computed properties and validation behind a clean property-style API
  • Abstract classes define partial implementations and enforce that subclasses complete the contract

Pro Tip: Prefer composition over inheritance for complex domains. Instead of a deep Animal → Mammal → Pet → Dog chain, consider giving a Dog class a Behavior object that encapsulates its specific traits. TypeScript interfaces make this pattern easy — define the behavior shape as an interface, pass it in the constructor, and keep each class shallow and focused.

Next Steps

Your ActivityStore was hardcoded to hold Activity values — but the tracker's real service should work for any record with an id, not just activities. Next, you'll learn generics: how to write one reusable, type-safe service (and function, and interface) that works across many types without falling back to any.

Ready to continue? Head to Generics!

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.