Skip to content

04 · Classes Advanced

Level 1 covered typed properties, constructors, and basic public/private/protected modifiers. This module builds on that: abstract classes that can't be instantiated directly, implements versus extends, static members, and a few access modifier subtleties that trip people up in real projects.

Abstract classes

An abstract class defines a partial implementation that subclasses must complete. You can't create an instance of it directly:

abstract class Shape {
  abstract area(): number;   // no body -- subclasses must implement it

  describe(): string {
    return `This shape has an area of ${this.area().toFixed(2)}`;
  }
}

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

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

const c = new Circle(3);
console.log(c.describe());   // This shape has an area of 28.27

// new Shape();
// error TS2511: Cannot create an instance of an abstract class.

describe() is a concrete method shared by all subclasses; area() is an abstract method — a contract with no implementation, forcing every subclass to supply its own. This is the key difference from a plain interface: an abstract class can mix shared, working code with must-override contracts in the same declaration.

implements vs extends

interface Flyable {
  fly(): string;
}

abstract class Bird {
  constructor(protected species: string) {}
  abstract sound(): string;
}

class Sparrow extends Bird implements Flyable {
  constructor() {
    super("Sparrow");
  }

  sound(): string {
    return "Chirp!";
  }

  fly(): string {
    return `${this.species} flies away`;
  }
}

const sparrow = new Sparrow();
console.log(sparrow.sound());   // Chirp!
console.log(sparrow.fly());     // Sparrow flies away

A class can extends at most one class (single inheritance) but can implements any number of interfaces — use extends for "is a kind of, and inherits behavior," and implements for "conforms to this contract" without inheriting any code.

Static members

static properties and methods belong to the class itself, not to instances:

class Counter {
  private static count = 0;

  constructor() {
    Counter.count += 1;
  }

  static get total(): number {
    return Counter.count;
  }
}

new Counter();
new Counter();
new Counter();

console.log(Counter.total);   // 3
// new Counter().count would be an error -- count is only on the class

Static members are useful for shared state (a registry, a counter) or factory methods that build instances without exposing every constructor detail:

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

  static origin(): Point {
    return new Point(0, 0);
  }

  static fromArray([x, y]: [number, number]): Point {
    return new Point(x, y);
  }
}

const p1 = Point.origin();
const p2 = Point.fromArray([3, 4]);
console.log(p1, p2);   // Point { x: 0, y: 0 } Point { x: 3, y: 4 }

// new Point(1, 1);
// error TS2673: Constructor of class 'Point' is private and only
// accessible within the class declaration.

A private constructor() forces every instance to go through a named static factory — a common pattern when you want to validate or normalize inputs before an object can exist at all.

readonly vs private — different jobs

They're often used together but solve different problems:

class Config {
  readonly version: string;      // can be READ from outside, never reassigned
  private secretKey: string;     // cannot be accessed from outside at all

  constructor(version: string, secretKey: string) {
    this.version = version;
    this.secretKey = secretKey;
  }

  maskedKey(): string {
    return this.secretKey.slice(0, 2) + "***";
  }
}

const cfg = new Config("1.0.0", "sk_live_abc123");
console.log(cfg.version);      // 1.0.0 -- readable from outside
console.log(cfg.maskedKey());  // sk***

// cfg.version = "2.0.0";
// error TS2540: Cannot assign to 'version' because it is a read-only property.
// console.log(cfg.secretKey);
// error TS2341: Property 'secretKey' is private and only accessible within class 'Config'.

readonly controls mutability; private/protected control visibility. A property can be both private readonly — set once in the constructor and never touched again, by anyone, including the class itself after construction.

A trap: access modifiers are compile-time only

TypeScript's private and protected are erased when compiled to JavaScript — they exist purely for the type checker, not as runtime enforcement:

class Wallet {
  private balance = 100;
}

const w = new Wallet();
// w.balance;
// error TS2341 at compile time...

// ...but the compiled JavaScript has no such protection:
console.log((w as any).balance);   // 100 -- the "private" field is fully
                                    // readable once you cast past the
                                    // type checker

If you need runtime-enforced privacy (not just compile-time), use a real JavaScript private field with the # prefix instead — those are enforced by the JS engine itself, not just by TypeScript:

class TrueWallet {
  #balance = 100;

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

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

const tw = new TrueWallet();
console.log(tw.balance);   // 100
// (tw as any).#balance would still be a syntax error -- #-fields are
// only accessible from inside the class body, enforced by JavaScript
// itself, no cast can reach them.

Overriding methods and calling super

class Employee {
  constructor(protected name: string, protected baseSalary: number) {}

  pay(): number {
    return this.baseSalary;
  }
}

class Manager extends Employee {
  constructor(name: string, baseSalary: number, private bonus: number) {
    super(name, baseSalary);
  }

  override pay(): number {
    return super.pay() + this.bonus;   // reuse the parent's logic, then add to it
  }
}

const mgr = new Manager("Dee", 60000, 15000);
console.log(mgr.pay());   // 75000

The override keyword (available since TypeScript 4.3) is optional but recommended: it tells the compiler "I intend to override a base class method," and if the base class method's name or signature ever changes or is removed, you get a compile error instead of a silently unused method.

How It Actually Works

Abstract classes and methods are, like most access modifiers, a compile-time-only restriction layered on top of ordinary JS classes: abstract class Shape { abstract area(): number } erases the abstract keyword entirely, and the emitted JS class is a normal, instantiable class with no area implementation — the "you cannot instantiate an abstract class" and "a subclass must implement abstract members" guarantees exist purely in the checker's bookkeeping (it tracks a hasAbstractModifier flag on the declaration and rejects new Shape() call expressions and subclasses missing the required override), and nothing prevents constructing the compiled class directly if you bypass the type checker (e.g., via // @ts-ignore or plain JS consuming the compiled output).

Method overriding is checked with the same contravariant/covariant rule from function types: a subclass method's parameters must accept at least what the base class's declared parameters promise, and its return type must be assignable to the base's return type — this is why widening an overridden method's parameter type is allowed (accepting more is safe) but narrowing it is a compile error (the base class's callers might pass something the override can't actually handle), a rule called method parameter bivariance that TypeScript actually relaxes slightly for methods (vs. strict contravariance for standalone function types) as a pragmatic trade-off, controlled by the strictFunctionTypes flag which does not apply to method syntax, only to function-typed properties.

super() calls are subject to a genuine runtime ordering constraint the checker enforces because JS itself enforces it: in a derived class, this is not initialized until super() returns (the base class constructor runs first), so the checker flags any use of this before the super() call as an error — not a stylistic type-safety feature, but the checker surfacing a real ReferenceError that would otherwise only be caught by actually running the code.

Cheat sheet

Feature Syntax Notes
Abstract class abstract class X { abstract m(): T; } Cannot be instantiated; subclasses must implement abstract members
Interface implementation class X implements I Any number of interfaces; contract only, no inherited code
Single inheritance class X extends Y At most one base class
Static member static prop / static method() Belongs to the class, not instances
Private constructor private constructor() {} Forces construction through static factories
Compile-time privacy private / protected Erased at runtime; a cast to any reaches them
Runtime privacy #field Enforced by JavaScript itself, even past a cast
Explicit override override method() Compiler checks the base method still exists

Exercise

Design an abstract class PaymentMethod with an abstract method charge(amount: number): string and a concrete method receipt(amount: number): string that calls charge and wraps the result. Create two subclasses, CreditCard (constructor takes a masked card number) and PayPal (constructor takes an email), each implementing charge differently. Add a static counter on PaymentMethod that tracks how many payment method instances have been created across all subclasses, and print the total after creating a few of each.