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 methodDog 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 DogEach 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 doneYou 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
class Vehicleconstructor(vin: string)class Car extends Vehicleconstructor(vin: string, make: string)super() callMethod 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 startedThe 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: 3The 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 superEach 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 protectedSubclasses 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 protectedThe 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 constructorProtected 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.