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.
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 itselfprotected— 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.
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());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 06-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?
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. Because each value line consumes a binding the previous line created — and the class must exist before it is instantiated — only one order runs top-to-bottom and logs 120.
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; } }console.log(spent.total());const wallet = new Wallet(100);const spent = funded.spend(30);const funded = wallet.add(50);
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.
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);
}
}
class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}
}
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.
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
Once it passes, try two variations and predict each before running:
- Count instead of sum. In
totalMinutes, changesum + a.minutesSpenttosum + 1so it counts activities rather than adding minutes. Predict which check fails first before running.totalMinutes(store)now returns2(two stored activities), so TODO 2's check fires withAssertionError: TODO 2: totalMinutes should sum minutesSpent across the storeand2 !== 50— the TODO 1 checks still pass, so this is the first failure. An instructive assert failure isolating summing a field from counting elements. - 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 getAll titles: Types, Narrowing. This changes the echoed output, not any check — and showsall()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. Lessons 07 (generics) and 10 (Partial/Pick DTOs) 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
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 callsuper()first - Interfaces used with
implementsact 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 → Dogchain, consider giving aDogclass aBehaviorobject 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!
Next lesson
Generics
Write reusable, type-safe code with TypeScript generics. Learn generic functions, interfaces, classes, and constraints.
25 min