TypeScriptInheritance & super

Inheritance & super

Inheritance allows a class to reuse and extend the behaviour of another class. TypeScript's class inheritance is built on JavaScript's prototype chain, with the added safety of static type checking. The super keyword is the bridge between a derived class and its parent.

Basic Inheritance with extends

A class that extends another inherits all of its public and protected members. The derived class can add new members, override existing ones, or simply reuse the parent's behaviour as-is.

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

  move(distance = 0): void {
    console.log(`${this.name} moved ${distance}m.`);
  }

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

class Dog extends Animal {
  breed: string;

  constructor(name: string, breed: string) {
    super(name); // ← must call super before using 'this'
    this.breed = breed;
  }

  bark(): void {
    console.log('Woof!');
  }

  // Inherited: move(), toString(), name property
}

const dog = new Dog('Rex', 'Labrador');
dog.bark();       // Woof!
dog.move(10);     // Rex moved 10m.
console.log(dog.name);  // Rex
console.log(dog.breed); // Labrador
Warning
In a derived class constructor, you MUST call super() before accessing this. TypeScript enforces this — you will get a compile error if you forget.
The super Keyword

super has two uses in TypeScript:

  1. super(...args) — calls the parent constructor (required in derived class constructors)
  2. super.method() — calls the parent's version of an overridden method

TS
class Vehicle {
  constructor(
    public make: string,
    public model: string,
    public year: number,
  ) {}

  describe(): string {
    return `${this.year} ${this.make} ${this.model}`;
  }

  start(): void {
    console.log('Engine started.');
  }
}

class ElectricVehicle extends Vehicle {
  constructor(
    make: string,
    model: string,
    year: number,
    public batteryCapacityKwh: number,
  ) {
    super(make, model, year); // ← calls Vehicle's constructor
  }

  // Override describe() — but augment rather than replace
  describe(): string {
    const base = super.describe(); // ← calls Vehicle's describe()
    return `${base} (Electric, ${this.batteryCapacityKwh}kWh)`;
  }

  // Override start() completely
  start(): void {
    // Do NOT call super.start() — different behaviour
    console.log('Electric motor engaged silently.');
  }
}

const tesla = new ElectricVehicle('Tesla', 'Model 3', 2024, 82);
console.log(tesla.describe()); // 2024 Tesla Model 3 (Electric, 82kWh)
tesla.start();                 // Electric motor engaged silently.
Method Overriding

A derived class can override any public or protected method from the parent. TypeScript checks that the override signature is compatible. If noImplicitOverride is enabled (recommended), you must use the override keyword explicitly.

TS
class Shape {
  area(): number {
    return 0;
  }

  perimeter(): number {
    return 0;
  }

  toString(): string {
    return `Shape(area=${this.area().toFixed(2)})`;
  }
}

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

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

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

  override toString(): string {
    return `Circle(r=${this.radius}, area=${this.area().toFixed(2)})`;
  }
}

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

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

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

const shapes: Shape[] = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach(s => console.log(s.area().toFixed(2)));
// 78.54
// 24.00
Note
The override keyword is optional unless you enable noImplicitOverride in tsconfig. With that flag on, TypeScript will error if you override a method without writing override — preventing accidental overrides when base class methods are renamed.
Access Modifiers in Inheritance

Modifier

Same class

Derived class

Outside class

public

protected

private

TS
class BankAccount {
  public readonly id: string;
  protected balance: number;
  private transactionLog: string[] = [];

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

  protected recordTransaction(note: string) {
    this.transactionLog.push(`${new Date().toISOString()}: ${note}`);
  }

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

class SavingsAccount extends BankAccount {
  private interestRate: number;

  constructor(id: string, initialBalance: number, interestRate: number) {
    super(id, initialBalance);
    this.interestRate = interestRate;
  }

  deposit(amount: number): void {
    this.balance += amount;              // ✅ protected — accessible
    this.recordTransaction(`Deposit: ${amount}`); // ✅ protected method

    // ❌ Error: Property 'transactionLog' is private in type 'BankAccount'
    // this.transactionLog.push('...');
  }

  applyInterest(): void {
    const interest = this.balance * this.interestRate;
    this.balance += interest;            // ✅ protected
    this.recordTransaction(`Interest: ${interest.toFixed(2)}`);
  }
}

const account = new SavingsAccount('SA001', 1000, 0.05);
account.deposit(500);
account.applyInterest();
console.log(account.getBalance()); // 1575
super in Property Accessors

super also works with getters and setters — you can call the parent's getter inside a derived getter.

TS
class Product {
  constructor(
    public name: string,
    protected basePrice: number,
  ) {}

  get price(): number {
    return this.basePrice;
  }

  get displayName(): string {
    return this.name;
  }
}

class DiscountedProduct extends Product {
  constructor(
    name: string,
    basePrice: number,
    private discountPercent: number,
  ) {
    super(name, basePrice);
  }

  override get price(): number {
    return super.price * (1 - this.discountPercent / 100);
  }

  override get displayName(): string {
    return `${super.displayName} (-${this.discountPercent}%)`;
  }
}

const item = new DiscountedProduct('Widget', 100, 20);
console.log(item.price);       // 80
console.log(item.displayName); // Widget (-20%)
Abstract Classes

An abstract class is a class that cannot be instantiated directly — it exists to be extended. Abstract methods must be implemented by derived classes.

Abstract classes occupy the middle ground between interfaces (no implementation) and concrete classes (full implementation).

TS
abstract class Renderer {
  // Abstract method — no implementation here, subclass must provide it
  abstract render(template: string, context: Record<string, unknown>): string;

  // Concrete method — shared by all subclasses
  renderToFile(
    path: string,
    template: string,
    context: Record<string, unknown>,
  ): void {
    const output = this.render(template, context); // calls the abstract method
    console.log(`Writing ${output.length} chars to ${path}`);
    // fs.writeFileSync(path, output);
  }

  // Template method pattern — defines the algorithm, delegates steps
  renderPage(templateName: string, data: Record<string, unknown>): string {
    const template = this.loadTemplate(templateName);
    const content  = this.render(template, data);
    return this.wrapInLayout(content);
  }

  protected loadTemplate(name: string): string {
    return `<template>${name}</template>`; // base implementation
  }

  private wrapInLayout(content: string): string {
    return `<html><body>${content}</body></html>`;
  }
}

// ❌ Error: Cannot create an instance of an abstract class
// new Renderer();

class HandlebarsRenderer extends Renderer {
  render(template: string, context: Record<string, unknown>): string {
    // Real implementation would use Handlebars.compile()
    return template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
      String(context[key] ?? ''),
    );
  }
}

class MustacheRenderer extends Renderer {
  render(template: string, context: Record<string, unknown>): string {
    return template.replace(/\{\{(\w+)\}\}/g, (_, key) =>
      String(context[key] ?? ''),
    );
  }

  protected override loadTemplate(name: string): string {
    return `<!-- Mustache: ${name} -->`;
  }
}

const r = new HandlebarsRenderer();
r.renderToFile('index.html', 'Hello {{name}}!', { name: 'World' });
Multi-Level Inheritance

Inheritance chains can span multiple levels. Each level can override methods and call super to reach any ancestor in the chain.

TS
class Base {
  greet(): string { return 'Hello from Base'; }
}

class Middle extends Base {
  override greet(): string {
    return `${super.greet()} + Middle`;
  }
}

class Leaf extends Middle {
  override greet(): string {
    return `${super.greet()} + Leaf`;
  }
}

const l = new Leaf();
console.log(l.greet());
// "Hello from Base + Middle + Leaf"
Warning
Deep inheritance hierarchies (more than 2–3 levels) become hard to reason about. Prefer composition over inheritance for complex behaviour.
instanceof and Type Narrowing

TypeScript narrows types in branches where instanceof checks succeed. This is especially useful with inheritance hierarchies.

TS
class HTTPError extends Error {
  constructor(
    public statusCode: number,
    message: string,
  ) {
    super(message);
    this.name = 'HTTPError';
  }
}

class NetworkError extends Error {
  constructor(
    public readonly originalError: Error,
    message = 'Network request failed',
  ) {
    super(message);
    this.name = 'NetworkError';
  }
}

class TimeoutError extends NetworkError {
  constructor(public timeoutMs: number) {
    super(new Error('timeout'), `Request timed out after ${timeoutMs}ms`);
    this.name = 'TimeoutError';
  }
}

function handleError(err: unknown): string {
  if (err instanceof TimeoutError) {
    // err: TimeoutError — access .timeoutMs
    return `Timeout after ${err.timeoutMs}ms — try again`;
  }
  if (err instanceof HTTPError) {
    // err: HTTPError — access .statusCode
    if (err.statusCode === 401) return 'Please log in again';
    if (err.statusCode === 404) return 'Resource not found';
    return `HTTP ${err.statusCode}: ${err.message}`;
  }
  if (err instanceof Error) {
    return `Error: ${err.message}`;
  }
  return 'An unknown error occurred';
}
Tip
Order instanceof checks from most specific to most general — TimeoutError before NetworkError before Error.
Composition vs Inheritance

Inheritance is powerful but has limits. Favour composition (combining behaviour through interfaces and member properties) over deep inheritance chains.

TS
// Inheritance approach — becomes rigid with multiple "flavours"
class LoggedUserRepository extends UserRepository {
  override async findById(id: string) {
    console.log(`findById: ${id}`);
    return super.findById(id);
  }
}
class CachedUserRepository extends UserRepository { /* ... */ }
class CachedAndLoggedUserRepository extends ??? { /* impossible cleanly */ }

// Composition approach — mix and match behaviours
interface UserRepo {
  findById(id: string): Promise<User | null>;
}

class LoggingDecorator implements UserRepo {
  constructor(private inner: UserRepo) {}
  async findById(id: string) {
    console.log(`findById: ${id}`);
    return this.inner.findById(id);
  }
}

class CachingDecorator implements UserRepo {
  private cache = new Map<string, User>();
  constructor(private inner: UserRepo) {}
  async findById(id: string) {
    if (this.cache.has(id)) return this.cache.get(id)!;
    const user = await this.inner.findById(id);
    if (user) this.cache.set(id, user);
    return user;
  }
}

// Compose freely — order determines behaviour
const repo = new CachingDecorator(new LoggingDecorator(new DbUserRepository()));
Success
The Decorator pattern shown above is a textbook example of the Open/Closed Principle — the UserRepository class is closed for modification but open for extension via decoration.
Summary
  • extends creates an inheritance relationship — the derived class gets all public and protected members.

  • super() must be called before accessing this in a derived constructor.

  • super.method() calls the parent class's version of an overridden method.

  • protected members are accessible in derived classes but not outside the hierarchy.

  • private members are not inherited — only the declaring class can access them.

  • The override keyword (required with noImplicitOverride) makes override intent explicit and safe.

  • Abstract classes define a partial implementation that subclasses must complete.

  • instanceof narrows types in conditional branches — order checks from most specific to most general.

  • Prefer composition over deep inheritance hierarchies for complex behaviour.