Logo

Home

About

Blog

Contact

Buy Merch

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Class Inheritance and the super Keyword

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Abstract Classes and Methods
TypeScript
8m
Mar 8, 2026

Abstract Classes and Methods

A comprehensive guide to TypeScript abstract classes covering abstract methods, partial implementation, inheritance patterns, and when to use abstract classes vs interfaces.

#TypeScript Abstract Classes#Abstract Methods+5
Static Members and Utility Classes
TypeScript
6m
Mar 1, 2026

Static Members and Utility Classes

A comprehensive guide to TypeScript static members covering static properties, static methods, utility classes, singleton pattern, and when to use class-level vs instance-level code.

#TypeScript Static#Static Methods+5
Getters, Setters, and Computed Properties
TypeScript
7m
Feb 22, 2026

Getters, Setters, and Computed Properties

A comprehensive guide to TypeScript accessor methods covering getters, setters, computed properties, validation patterns, and best practices for controlled property access.

#TypeScript Getters#TypeScript Setters+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Understanding Inheritance
  • The extends Keyword
  • Constructor Chaining with super
  • Method Overriding
  • Accessing Parent Methods with super
  • Protected Members in Inheritance
  • Inheritance vs Composition
  • Best Practices and Common Pitfalls
TypeScript

Class Inheritance and the super Keyword

March 15, 2026•13 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript class inheritance and super keyword visualization
Class Inheritance and the super Keyword

Understanding Inheritance

Inheritance lets you create new classes based on existing ones. The new class (child/subclass/derived class) inherits properties and methods from the existing class (parent/superclass/base class), and can add its own or override what it inherited.

Think of inheritance as specialization. A Dog is a specialized type of Animal. It has everything an animal has, plus dog-specific features. You don't rewrite all the animal functionality—you inherit it and add what makes a dog unique.

class Animal {
  constructor(public name: string) {}
 
  move(distance: number): void {
    console.log(`${this.name} moved ${distance} meters`);
  }
 
  eat(): void {
    console.log(`${this.name} is eating`);
  }
}
 
class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} barks: Woof!`);
  }
}
 
const dog = new Dog("Buddy");
dog.move(10); // Inherited from Animal
dog.eat(); // Inherited from Animal
dog.bark(); // Dog-specific method

Dog inherits move() and eat() from Animal. It also adds bark(). Instances of Dog can use all three methods.

Inheritance Creates 'Is-A' Relationships

A Dog is an Animal. A Car is a Vehicle. A Manager is an Employee. If you can't say "X is a Y," inheritance probably isn't the right tool. Use composition instead.

The extends Keyword

The extends keyword creates an inheritance relationship. The syntax is simple: class Child extends Parent.

Basic Extension

class Vehicle {
  constructor(public model: string) {}
 
  start(): void {
    console.log(`${this.model} starting...`);
  }
 
  stop(): void {
    console.log(`${this.model} stopping...`);
  }
}
 
class Car extends Vehicle {
  honk(): void {
    console.log(`${this.model}: Beep beep!`);
  }
}
 
class Motorcycle extends Vehicle {
  wheelie(): void {
    console.log(`${this.model} doing a wheelie!`);
  }
}
 
const car = new Car("Tesla Model 3");
car.start(); // "Tesla Model 3 starting..."
car.honk(); // "Tesla Model 3: Beep beep!"
 
const bike = new Motorcycle("Harley Davidson");
bike.start(); // "Harley Davidson starting..."
bike.wheelie(); // "Harley Davidson doing a wheelie!"

Both Car and Motorcycle inherit start() and stop() from Vehicle. Each adds its own specialized methods.

Multi-Level Inheritance

You can create inheritance chains:

class LivingThing {
  breathe(): void {
    console.log("Breathing...");
  }
}
 
class Animal extends LivingThing {
  move(): void {
    console.log("Moving...");
  }
}
 
class Mammal extends Animal {
  feedYoung(): void {
    console.log("Feeding young with milk...");
  }
}
 
class Dog extends Mammal {
  bark(): void {
    console.log("Woof!");
  }
}
 
const dog = new Dog();
dog.breathe(); // From LivingThing
dog.move(); // From Animal
dog.feedYoung(); // From Mammal
dog.bark(); // From Dog

Each class adds its own layer of functionality. Dog inherits from the entire chain.

Deep Hierarchies Are Fragile

Keep inheritance hierarchies shallow (2-3 levels max). Deep hierarchies become brittle—changes to a parent class ripple through many children. If you need more than 3 levels, consider using composition or interfaces instead.

Constructor Chaining with super

When a class has a constructor and extends another class with a constructor, you must call super() before accessing this. This ensures the parent is initialized before the child adds its own initialization.

Basic Constructor Chaining

class Person {
  constructor(
    public name: string,
    public age: number
  ) {
    console.log("Person constructor");
  }
}
 
class Employee extends Person {
  constructor(
    name: string,
    age: number,
    public employeeId: string
  ) {
    super(name, age); // Must call parent constructor first
    console.log("Employee constructor");
  }
}
 
const emp = new Employee("Alice", 30, "EMP001");
// Output:
// Person constructor
// Employee constructor
 
console.log(emp.name); // "Alice"
console.log(emp.age); // 30
console.log(emp.employeeId); // "EMP001"

The super(name, age) call invokes the parent constructor with the required arguments. This happens before the child constructor runs its own logic.

Constructor Order Matters

class Vehicle {
  constructor(public vin: string) {
    console.log(`1. Vehicle constructor: VIN ${vin}`);
  }
}
 
class Car extends Vehicle {
  public make: string;
 
  constructor(vin: string, make: string) {
    console.log("2. Car constructor starting");
 
    // super(vin); // Error: 'super' must be called before accessing 'this'
    // this.make = make; // Error: Can't use 'this' before 'super()'
 
    super(vin); // Must come first
    console.log("3. After super() call");
 
    this.make = make; // Now 'this' is safe to use
    console.log("4. Car constructor done");
  }
}
 
const car = new Car("1HGBH41JXMN109186", "Honda");
// Output:
// 1. Vehicle constructor: VIN 1HGBH41JXMN109186
// 2. Car constructor starting
// 3. After super() call
// 4. Car constructor done

You cannot access this before calling super() because the parent hasn't initialized yet.

Parent Constructor with Default Values

class Shape {
  constructor(
    public color: string = "black",
    public filled: boolean = false
  ) {}
 
  describe(): string {
    const fillStatus = this.filled ? "filled" : "outlined";
    return `${this.color} ${fillStatus} shape`;
  }
}
 
class Circle extends Shape {
  constructor(
    public radius: number,
    color?: string,
    filled?: boolean
  ) {
    super(color, filled); // Pass to parent (can be undefined)
  }
 
  getArea(): number {
    return Math.PI * this.radius * this.radius;
  }
}
 
const circle1 = new Circle(5);
console.log(circle1.describe()); // "black outlined shape"
 
const circle2 = new Circle(10, "red", true);
console.log(circle2.describe()); // "red filled shape"

The parent's default parameters work even when called from child constructors.

Constructor Chaining Flow

How super() creates the inheritance chain

Parent
class Vehicle
constructor(vin: string)
Child
class Car extends Vehicle
constructor(vin: string, make: string)
Execution Order:
1
1. Child constructor called
2
2. super(vin) invokes parent constructor
3
3. Parent initializes (vin property)
4
4. Parent constructor completes
5
5. Control returns to child
6
6. Child initializes (make property)
7
7. Child constructor completes
super() call
Parent constructor execution
Child constructor execution

Method Overriding

Subclasses can replace methods inherited from parents. The child's version takes precedence.

Basic Override

class Animal {
  makeSound(): void {
    console.log("Some generic animal sound");
  }
}
 
class Dog extends Animal {
  makeSound(): void {
    console.log("Woof!");
  }
}
 
class Cat extends Animal {
  makeSound(): void {
    console.log("Meow!");
  }
}
 
const animals: Animal[] = [new Animal(), new Dog(), new Cat()];
 
animals.forEach((animal) => animal.makeSound());
// Output:
// Some generic animal sound
// Woof!
// Meow!

Each class provides its own implementation of makeSound(). The specific version called depends on the actual object type, not the variable type (polymorphism).

Override with Different Behavior

class BankAccount {
  constructor(protected balance: number = 0) {}
 
  deposit(amount: number): void {
    this.balance += amount;
    console.log(`Deposited \$${amount}. New balance: \$${this.balance}`);
  }
 
  withdraw(amount: number): boolean {
    if (amount <= this.balance) {
      this.balance -= amount;
      console.log(`Withdrew \$${amount}. New balance: \$${this.balance}`);
      return true;
    }
    console.log("Insufficient funds");
    return false;
  }
 
  getBalance(): number {
    return this.balance;
  }
}
 
class SavingsAccount extends BankAccount {
  private interestRate: number = 0.02;
 
  // Override to add interest tracking
  deposit(amount: number): void {
    const interest = amount * this.interestRate;
    this.balance += amount + interest;
    console.log(
      `Deposited \$${amount} + \$${interest.toFixed(2)} interest. Balance: \$${this.balance.toFixed(2)}`
    );
  }
}
 
class CheckingAccount extends BankAccount {
  private overdraftLimit: number = 500;
 
  // Override to allow overdraft
  withdraw(amount: number): boolean {
    if (amount <= this.balance + this.overdraftLimit) {
      this.balance -= amount;
      console.log(`Withdrew \$${amount}. Balance: \$${this.balance}`);
      if (this.balance < 0) {
        console.log(`  (Overdrafted by \$${Math.abs(this.balance)})`);
      }
      return true;
    }
    console.log("Exceeds overdraft limit");
    return false;
  }
}
 
const savings = new SavingsAccount(1000);
savings.deposit(100); // "Deposited $100 + $2.00 interest. Balance: $1102.00"
 
const checking = new CheckingAccount(100);
checking.withdraw(400); // "Withdrew $400. Balance: $-300"
// "  (Overdrafted by $300)"

Both subclasses override methods to implement account-specific rules while keeping the parent's interface.

Type Safety in Overrides

TypeScript ensures overridden methods maintain compatible signatures:

class Base {
  process(data: string): string {
    return data.toUpperCase();
  }
}
 
class Derived extends Base {
  // ✅ Valid: same signature
  process(data: string): string {
    return data.toLowerCase();
  }
}
 
class Invalid extends Base {
  // ❌ Error: parameter type mismatch
  // process(data: number): string {
  //   return data.toString();
  // }
  // ❌ Error: return type mismatch
  // process(data: string): number {
  //   return data.length;
  // }
}

Overridden methods must accept the same parameter types and return compatible types.

Accessing Parent Methods with super

The super keyword also accesses parent class methods. This lets you extend parent behavior rather than completely replacing it.

Calling Parent Methods

class Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}
 
class TimestampLogger extends Logger {
  log(message: string): void {
    const timestamp = new Date().toISOString();
    super.log(`[${timestamp}] ${message}`);
  }
}
 
const logger = new TimestampLogger();
logger.log("Application started");
// Output: [LOG] [2026-02-06T12:34:56.789Z] Application started

The child calls the parent's log() method after adding a timestamp. It enhances rather than replaces.

Building on Parent Functionality

class Counter {
  protected count: number = 0;
 
  increment(): void {
    this.count++;
  }
 
  getValue(): number {
    return this.count;
  }
 
  reset(): void {
    this.count = 0;
    console.log("Counter reset");
  }
}
 
class LimitedCounter extends Counter {
  constructor(private max: number = 10) {
    super();
  }
 
  increment(): void {
    if (this.count < this.max) {
      super.increment(); // Call parent's increment
    } else {
      console.log(`Cannot exceed maximum of ${this.max}`);
    }
  }
 
  reset(): void {
    super.reset(); // Call parent's reset
    console.log(`  Max limit: ${this.max}`);
  }
}
 
const counter = new LimitedCounter(3);
counter.increment(); // count = 1
counter.increment(); // count = 2
counter.increment(); // count = 3
counter.increment(); // "Cannot exceed maximum of 3"
console.log(counter.getValue()); // 3
 
counter.reset();
// Output:
// Counter reset
//   Max limit: 3

The child adds validation before calling the parent's increment(), and adds extra logging after the parent's reset().

Multi-Level super Calls

class A {
  method(): void {
    console.log("A.method()");
  }
}
 
class B extends A {
  method(): void {
    console.log("B.method() - before super");
    super.method();
    console.log("B.method() - after super");
  }
}
 
class C extends B {
  method(): void {
    console.log("C.method() - before super");
    super.method();
    console.log("C.method() - after super");
  }
}
 
const obj = new C();
obj.method();
// Output:
// C.method() - before super
// B.method() - before super
// A.method()
// B.method() - after super
// C.method() - after super

Each class in the chain can call super.method() to invoke the next level up. This creates a call chain through the hierarchy.

When to Use super

Use super.method() when you want to augment parent behavior (add before/after logic) rather than completely replace it. This preserves the parent's logic while adding your own enhancements.

Protected Members in Inheritance

Protected members are accessible to the class itself and all its subclasses, but not to external code. They're perfect for sharing internal details within an inheritance hierarchy.

Protected Properties

class Game {
  protected score: number = 0;
  protected level: number = 1;
 
  protected increaseScore(points: number): void {
    this.score += points;
  }
 
  showStats(): void {
    console.log(`Level ${this.level} - Score: ${this.score}`);
  }
}
 
class PlatformGame extends Game {
  collectCoin(): void {
    this.increaseScore(10); // Protected method accessible
    console.log("Coin collected!");
  }
 
  completeLevel(): void {
    this.level++; // Protected property accessible
    this.increaseScore(100);
    console.log(`Level ${this.level} unlocked!`);
  }
}
 
const game = new PlatformGame();
game.collectCoin(); // "Coin collected!"
game.completeLevel(); // "Level 2 unlocked!"
game.showStats(); // "Level 2 - Score: 110"
 
// game.score = 1000; // Error: 'score' is protected
// game.increaseScore(50); // Error: 'increaseScore' is protected

Subclasses can access score, level, and increaseScore(), but external code cannot.

Protected Methods for Template Methods

class DataProcessor {
  process(data: string[]): string[] {
    const validated = this.validate(data);
    const transformed = this.transform(validated);
    const filtered = this.filter(transformed);
    return filtered;
  }
 
  protected validate(data: string[]): string[] {
    return data.filter((item) => item.trim().length > 0);
  }
 
  // Protected template methods - subclasses override
  protected transform(data: string[]): string[] {
    return data;
  }
 
  protected filter(data: string[]): string[] {
    return data;
  }
}
 
class EmailProcessor extends DataProcessor {
  protected transform(data: string[]): string[] {
    return data.map((email) => email.toLowerCase().trim());
  }
 
  protected filter(data: string[]): string[] {
    return data.filter((email) => email.includes("@"));
  }
}
 
const processor = new EmailProcessor();
const emails = processor.process([
  "  [email protected]  ",
  "invalid",
  "[email protected]",
]);
console.log(emails); // ["[email protected]", "[email protected]"]
 
// processor.validate([...]); // Error: 'validate' is protected

The public process() method orchestrates protected template methods that subclasses can override.

Protected Constructors

class Singleton {
  private static instance: Singleton;
 
  // Protected constructor - only subclasses can call
  protected constructor(public name: string) {}
 
  static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton("Default");
    }
    return Singleton.instance;
  }
}
 
class CustomSingleton extends Singleton {
  private static customInstance: CustomSingleton;
 
  private constructor(name: string) {
    super(name);
  }
 
  static getCustomInstance(): CustomSingleton {
    if (!CustomSingleton.customInstance) {
      CustomSingleton.customInstance = new CustomSingleton("Custom");
    }
    return CustomSingleton.customInstance;
  }
}
 
const s1 = Singleton.getInstance();
const s2 = CustomSingleton.getCustomInstance();
 
// const s3 = new Singleton("Test"); // Error: protected constructor

Protected constructors allow subclassing while preventing direct instantiation.

Inheritance vs Composition

Inheritance is powerful but often overused. Composition (using objects that contain other objects) is frequently a better choice.

When Inheritance Makes Sense

// ✅ Good: Clear "is-a" relationship
class Vehicle {
  start(): void {
    /* ... */
  }
  stop(): void {
    /* ... */
  }
}
 
class Car extends Vehicle {
  // A car IS A vehicle
}

When Composition Is Better

// ❌ Bad: Inheritance for code reuse
class Logger {
  log(msg: string): void {
    /* ... */
  }
}
 
class User extends Logger {
  // A user is NOT a logger!
}
 
// ✅ Good: Composition
class UserGood {
  private logger = new Logger();
 
  doSomething(): void {
    this.logger.log("User did something");
  }
}

Comparison Example

// Inheritance approach
class Animal {
  eat(): void {
    console.log("Eating...");
  }
  sleep(): void {
    console.log("Sleeping...");
  }
}
 
class FlyingAnimal extends Animal {
  fly(): void {
    console.log("Flying...");
  }
}
 
class SwimmingAnimal extends Animal {
  swim(): void {
    console.log("Swimming...");
  }
}
 
// What about a duck that flies AND swims?
// Can't extend both!
 
// Composition approach
interface Eater {
  eat(): void;
}
 
interface Flyer {
  fly(): void;
}
 
interface Swimmer {
  swim(): void;
}
 
class Duck implements Eater, Flyer, Swimmer {
  eat(): void {
    console.log("Duck eating...");
  }
  fly(): void {
    console.log("Duck flying...");
  }
  swim(): void {
    console.log("Duck swimming...");
  }
}
 
// Much more flexible!

Favor Composition Over Inheritance

The classic OOP guideline: "Favor composition over inheritance." Use inheritance for true "is-a" relationships (Dog is an Animal). Use composition for "has-a" or "uses-a" relationships (Car has an Engine, User uses a Logger).

Best Practices and Common Pitfalls

Best Practices

✅ Keep hierarchies shallow

// ✅ Good: 2-3 levels
class Entity {}
class User extends Entity {}
class AdminUser extends User {}
 
// ❌ Bad: Deep hierarchy
class Thing {}
class Item extends Thing {}
class Product extends Item {}
class PhysicalProduct extends Product {}
class ShippableProduct extends PhysicalProduct {}
// Too deep!

✅ Make base classes abstract when they shouldn't be instantiated

// ✅ Good
abstract class Shape {
  abstract getArea(): number;
}
 
// ❌ Bad
class ShapeBad {
  getArea(): number {
    return 0; // Nonsensical
  }
}

✅ Use protected for internal inheritance contracts

class Base {
  // Protected: subclasses can access
  protected helperMethod(): void {
    /* ... */
  }
 
  // Public: external API
  public doSomething(): void {
    this.helperMethod();
  }
}

✅ Document overridable methods

class Base {
  /**
   * Process data. Override this to customize processing logic.
   * Always call super.process() to maintain parent behavior.
   */
  protected process(data: string): string {
    return data.trim();
  }
}

Common Pitfalls

❌ Forgetting to call super() in constructors

class Parent {
  constructor(public name: string) {}
}
 
class Child extends Parent {
  constructor(name: string) {
    // super(name); // Forgot this!
    // Error: 'this' is not defined
  }
}

❌ Breaking Liskov Substitution Principle

class Rectangle {
  constructor(
    public width: number,
    public height: number
  ) {}
 
  setWidth(w: number): void {
    this.width = w;
  }
 
  setHeight(h: number): void {
    this.height = h;
  }
 
  getArea(): number {
    return this.width * this.height;
  }
}
 
// ❌ Bad: Square violates rectangle's contract
class Square extends Rectangle {
  setWidth(w: number): void {
    this.width = w;
    this.height = w; // Side effect!
  }
 
  setHeight(h: number): void {
    this.width = h; // Side effect!
    this.height = h;
  }
}
 
function testRectangle(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  console.log(rect.getArea()); // Expects 50
}
 
testRectangle(new Rectangle(0, 0)); // 50 ✅
testRectangle(new Square(0, 0)); // 100 ❌ - Broken!

❌ Overriding without understanding parent behavior

class Base {
  important(): void {
    this.step1();
    this.step2();
    this.step3();
  }
 
  protected step1(): void {
    /* critical setup */
  }
  protected step2(): void {
    /* operation */
  }
  protected step3(): void {
    /* cleanup */
  }
}
 
class Derived extends Base {
  // ❌ Bad: Skips critical steps
  important(): void {
    this.step2(); // Only does step2, skips setup and cleanup!
  }
}

Liskov Substitution Principle

Subclasses should be substitutable for their parent classes without breaking functionality. If code that works with the parent breaks with the child, your inheritance design is flawed. Use composition or rethink your hierarchy.

What's Next

You now understand class inheritance and the super keyword. In the next posts, you'll learn:

  • Implementing interfaces in classes
  • Parameter properties shorthand
  • Mixins for multiple inheritance patterns
  • Decorators for class metadata

Key Takeaways

Class inheritance gives you:

✅ Code reuse - share functionality across related classes ✅ Specialization - create specific versions of general concepts ✅ Polymorphism - treat different classes through common interfaces ✅ Constructor chaining - build complex initialization sequences ✅ Method overriding - customize behavior while preserving structure ✅ Protected access - share internals within hierarchies

Use inheritance for true "is-a" relationships. Keep hierarchies shallow. Always call super() in constructors before using this. Use super.method() to enhance rather than replace parent behavior. And remember: favor composition over inheritance when you don't have a clear "is-a" relationship.