Abstract Classes in TypeScript

In TypeScript, abstract classes serve as "blueprints for other blueprints." Unlike regular classes that you can use to create objects immediately, an abstract class is intentionally incomplete. It allows you to define a common structure, shared logic, and mandatory methods that every subclass must implement, ensuring consistency across your codebase.

Think of an abstract class as a template. For example, in a payment system, you might have a generic PaymentProcessor. You can't just "process a payment" without knowing the method (Credit Card, PayPal, etc.), but every processor will share certain traits like a transaction ID or a validate() method.

Developer Tip: Use abstract classes when you want to share code among several closely related classes (the "is-a" relationship), but you want to prevent anyone from using the base class on its own.

 

Key Points About Abstract Classes:

  1. Cannot be instantiated: You cannot use the new keyword on an abstract class. It exists only to be inherited.
  2. Abstract methods: These are method signatures without a body. They act as a "contract" any non-abstract subclass must provide its own logic for these methods.
  3. Regular methods: Unlike Interfaces, abstract classes can contain fully implemented methods that subclasses can use or override.
  4. Member Visibility: You can use access modifiers like private, protected, and public to control how subclasses interact with the base data.
Common Mistake: Beginners often try to instantiate an abstract class like this: const myItem = new BaseItem();. This will cause a TypeScript compiler error.

 

Declaring an Abstract Class

To define an abstract class, simply prefix the class keyword with abstract. To define an abstract method, place abstract before the method name and omit the function body (the curly braces).

Example:

abstract class Animal {
  // Shared property for all animals
  constructor(public name: string) {}

  // Abstract method: Every animal makes a sound, but in different ways
  abstract speak(): void;

  // Regular method: All animals breathe the same way in this logic
  breathe(): void {
    console.log(`${this.name} is breathing...`);
  }
}

class Dog extends Animal {
  // Implementing the required abstract method
  speak(): void {
    console.log(`${this.name} barks: Woof! Woof!`);
  }
}

const myDog = new Dog("Buddy");
myDog.speak();   // Output: Buddy barks: Woof! Woof!
myDog.breathe(); // Output: Buddy is breathing...

In this example:

  • The Animal class provides the breathe() logic so we don't have to rewrite it for every animal.
  • The Dog class is forced to implement speak(), otherwise, the code won't compile.
  • We gain the benefit of polymorphism: we can treat different animals as the generic type Animal while calling their specific speak() behaviors.
Best Practice: Use abstract methods for behaviors that are specific to each subclass, and regular methods for logic that is identical across all subclasses.

Abstract Methods

Abstract methods are powerful because they guarantee that a specific functionality exists without the base class needing to know how it works. This is perfect for complex systems like a Reporting Tool where every report has a different generate() logic but shares a saveToDisk() method.

Example:

abstract class Vehicle {
  abstract startEngine(): void;  // Subclasses must define this

  move(): void {
    console.log("The vehicle is rolling down the road.");
  }
}

class Car extends Vehicle {
  startEngine(): void {
    console.log("Ignition on: VRRRUM!");
  }
}

class ElectricScooter extends Vehicle {
  startEngine(): void {
    console.log("System on: (Silent hum)");
  }
}

const myCar = new Car();
myCar.startEngine(); // Custom logic
myCar.move();        // Shared logic
Watch Out: If a subclass extends an abstract class but fails to implement an abstract method, that subclass must also be declared abstract.

Abstract Class with Constructor

Even though you can't create an instance of an abstract class, it can still have a constructor. This constructor is used to initialize properties that are shared by all subclasses. Subclasses call this using the super() keyword.

Example:

abstract class Shape {
  // Shorthand for: this.color = color
  constructor(public color: string) {}

  abstract getArea(): number;
}

class Circle extends Shape {
  constructor(color: string, public radius: number) {
    super(color);  // Passing color up to the Shape constructor
  }

  getArea(): number {
    return Math.PI * Math.pow(this.radius, 2);
  }
}

const greenCircle = new Circle("green", 10);
console.log(greenCircle.color);    // Output: green
console.log(greenCircle.getArea()); // Output: 314.15...

In this logic, the Shape class handles the color, while the Circle class focuses purely on its own geometry calculations. This separation of concerns makes your code much cleaner.

Abstract Classes and Interfaces

A common question is: "When should I use an Interface versus an Abstract Class?" Use an Interface when you only need to define a shape (contracts). Use an Abstract Class when you want to define a shape and provide some shared, reusable code.

Interestingly, an abstract class can implement an interface. This is useful when you want to satisfy part of an interface but leave the rest for specific subclasses.

Example:

interface Movable {
  speed: number;
  move(): void;
}

abstract class Vehicle implements Movable {
  constructor(public speed: number) {}

  // We leave move() abstract for subclasses to define
  abstract move(): void;

  stopEngine(): void {
    console.log("Engine stopped.");
  }
}

class Bike extends Vehicle {
  move(): void {
    console.log(`Pedaling at ${this.speed} mph.`);
  }
}

const myBike = new Bike(15);
myBike.move(); // Output: Pedaling at 15 mph.
Developer Tip: Abstract classes are excellent for the Template Method Pattern, where the base class defines the "steps" of an algorithm but allows subclasses to provide the implementation for specific steps.

 

Summary

Abstract classes in TypeScript are a foundational tool for Object-Oriented Programming (OOP). They provide a middle ground between the total flexibility of a regular class and the strict contract-only nature of an interface. Key takeaways include:

  • Contract Enforcement: abstract methods ensure subclasses don't forget vital functionality.
  • Code Reuse: Non-abstract methods allow you to write logic once and use it in dozens of subclasses.
  • Control: They prevent the accidental creation of "generic" objects that aren't fully formed (like a generic "Animal" or "Vehicle").
  • Hierarchy: They help organize your code into logical families of objects.

By mastering abstract classes, you can build more robust, scalable, and predictable TypeScript applications.