Master TypeScript Interfaces and Inheritance

Mastering Scalable Node.js: A Guide to TypeScript Interfaces and Inheritance

Are your Node.js projects getting harder to manage as they grow? You aren’t alone. As features pile up, codebases often become brittle and difficult to maintain.

The key to writing scalable, maintainable code often lies in leveraging powerful Object-Oriented Programming (OOP) concepts. If you are using TypeScript, you have access to two of the best tools for this job: Interfaces and Inheritance.

In this post we’ll break down exactly how to use interfaces to enforce structure and inheritance to reuse code efficiently in your TypeScript projects.

🚀 Complete JavaScript Guide (Beginner + Advanced)

🚀 NodeJS – The Complete Guide (MVC, REST APIs, GraphQL, Deno)

The Challenge: Managing Diverse Implementation

Imagine a simple scenario. We have a Car class and a Driver class. The driver needs to call a drive() method on the car.

At first, you might hardcode the Driver to accept a Car instance. But what happens when you introduce different types of vehicles, like a Truck or a Motorcycle? You need a way to ensure that whatever vehicle the driver is given, it definitely has a drive() method, without the driver needing to know the exact class name.

This is where Interfaces shine.

1. Enforcing Structure with Interfaces

In Object-Oriented Programming, an interface acts as a contract. It ensures that any class that “implements” it adheres to a specific shape and contains certain methods. Unlike classes, interfaces don’t contain implementation logic; they just define signatures.

Let’s define an interface for anything that can be driven. By convention, we often prefix interfaces with a capital “I”.

// Define the contract
interface IDrivable {
    drive(): void;
}

Now, we can update our classes to adhere to this contract using the implements keyword. If a class claims to implement IDrivable but forgets the drive() method, TypeScript will throw an error.

class Car implements IDrivable {
    drive() {
        console.log("Toyota goes vroom vroom");
    }
}

class Truck implements IDrivable {
    // The implementation can be different, as long as the method signature matches
    drive() {
        console.log("Ford goes Rrrrr");
    }
}

The real power here is polymorphism. We can now update our Driver class to accept anything that adheres to the IDrivable interface, rather than a specific concrete class.

class Driver {
    // The driver doesn't care if it's a Car or Truck, only that it's IDrivable
    operateVehicle(vehicle: IDrivable) {
        vehicle.drive();
    }
}

This allows you to swap functionality on the fly based on configurations or runtime conditions without breaking your application.


2. Reusing Code with Inheritance

Interfaces solve the structure problem, but what about duplicated code?

You might notice that your Car and Truck classes share a lot of the same properties, such as productionDate or constructor logic. Repeating this code violates the DRY (Don’t Repeat Yourself) principle.

To solve this, we use Inheritance. We can create a parent class to hold the shared logic.

// The Parent Class holding shared logic
class Vehicle {
    productionDate: Date;

    constructor(productionDate: Date) {
        this.productionDate = productionDate;
    }
}

Now, Car and Truck can inherit from this parent using the extends keyword.

Note: A class in TypeScript can implement multiple interfaces (comma-separated), but it can only extend one parent class.

Understanding Visibility Modifiers

When dealing with inheritance, you need to manage who can access properties and methods. TypeScript provides three main modifiers:

  1. public (default): Accessible from anywhere—inside the class, in subclasses, and outside instances.
  2. private: Only accessible within the exact class where it is defined. Even subclasses cannot access it.
  3. protected: The middle ground. Accessible within the class itself and by any subclasses (children), but not accessible from outside instances.

If we want our Car and Truck children to access properties defined in the parent Vehicle, we should usually mark them as protected.


3. The Ultimate Combination: Abstract Classes

We’ve used interfaces for structure and inheritance for reuse. But what if we want both simultaneously?

Often, a parent class like Vehicle is just a concept; you wouldn’t want anyone to instantiate a generic new Vehicle() directly. You only want specific Cars or Trucks.

We can achieve this by marking the parent class as abstract.

An abstract class cannot be instantiated. Furthermore, it allows you to define abstract methods—methods that have no implementation in the parent but must be implemented by the child classes. It acts as both a parent class and an interface.

Let’s refine our final TypeScript code:

abstract class Vehicle {
    protected productionDate: Date;
    protected make: string;

    constructor(make: string) {
        this.make = make;
        this.productionDate = new Date();
    }

    // Abstract method: Children MUST implement this
    abstract drive(): void;
}

class Car extends Vehicle {
    drive() {
        console.log(`${this.make} goes vroom vroom`);
    }
}

class Truck extends Vehicle {
    drive() {
        console.log(`${this.make} goes Rrrrr`);
    }
}

Now, our Driver class can simply accept type Vehicle, knowing it will have both the shared properties and the enforced drive() method.

Bonus Tip: Constructor Signatures

Sometimes you need to pass a class itself to a function, rather than an instance of a class—for example, in a “factory” function that generates new vehicles.

You cannot simply pass the type Vehicle if it’s abstract, because you cannot instantiate it with new. You need to use a special type called a construct signature.

It looks like an arrow function, defining the parameters the constructor expects and the type it returns:

// A factory function accepting a constructor signature
function createVehicle(
    vehicleConstructor: new (make: string) => Vehicle, 
    make: string
): Vehicle {
    return new vehicleConstructor(make);
}

// Usage: Pass the concrete class itself
const myCar = createVehicle(Car, "Toyota");
const myTruck = createVehicle(Truck, "Ford");

Conclusion

Interfaces and inheritance are powerful tools in your TypeScript arsenal. They help you move away from messy, tangled Node.js code towards systems that are cleaner, more flexible, and easier to extend.

Share this article

Similar Posts