TypeScript Interfaces vs. Classes

February 8, 2018 by Chris Sherman

TypeScript interfaces and classes define what objects look like and provide type-checking. The choice the two structures depends on how much control we need over the implementation details of the objects we create. This article will explain the details of interfaces and classes and provide examples of when it makes sense to use each structure.

TypeScript Interfaces

Interfaces define the shape objects must have. We don’t control access to the properties of an interface nor do we implement its functions.

interface CarDoor {
  color: string;
  position: number;
}
 
function logCarDoorColor(carDoor: CarDoor) {
  console.log('The color of the door is: ' + carDoor.color);
}
 
let myDoor: CarDoor;
myDoor.color = 'red';
myDoor.position = 0;
logCarDoorColor(myDoor);

In the example, we define an interface CarDoor with two properties. We use the interface to ensure calls to logCarDoorColor receive a parameter of shape CarDoor. When we create the object, myDoor, we successfully call logCarDoorColor because it has a color property of type string and a position property of type number; thus, the object conforms to the shape of the CarDoor interface.

Since car doors are made to be opened, let’s add a function to our interface called openDoor. In our implementation, we’ll have this function adjust the position of the door by the number of degrees we pass into the function. We know that car doors have a maximum amount they can be swung open. We’ll assume we can open our door a maximum of 45 degrees.

interface CarDoor {
    color: string;
    position: number;
 
    openDoor(degrees: number);
}
 
const myDoor: CarDoor = {
    color: 'red',
    position: 0,
 
    openDoor(degrees: number): number {
        const maximumPosition = 45;
        const newPosition = this.position + degrees;
 
        if (degrees < 0) {
            throw new Error('Open door increases the position of ' +
                'the door. Please use a value greater than zero.');
        }
 
        if (newPosition > maximumPosition) {
            throw new Error('The door\'s position must be less ' +
                `than ${maximumPosition} degrees`);
        }
 
        this.position = newPosition;
        return this.position;
    }
};

Looking at the openDoor function, our implementation feels a bit awkward. Since CarDoor is an interface, we have a constant in the openDoor function establishing the door’s maximum position. Currently, we only access this property in openDoor, but later we may add an isDoorOpenFully function to determine if the door is at its maximum position. We could add a maximumPosition property to the interface, but the maximum position of a door is not easily changed; our object should reflect this. At this point, we should consider making CarDoor a class instead of an interface.

TypeScript Classes

Classes act as blueprints for creating objects. Like interfaces, each class has a defined set of properties and functions that define the shape of an object. Additionally, classes have an underlying implementation that defines how the properties and functions work together. When we create an object from a class, we call that object an instance of the class.

To understand what it means to be an instance of a class, consider a car door created in a factory. First we create the frame of the door via die casting. Once the door is removed from the die, it moves down an assembly line where we add components like hinges and door handles. At the end of the line, we have a car door with properties that make up the door and actions that can be taken on it.

Our class definition is able to model the process of creating a car door. Just as we create instances of the door by forcing metal into the die, removing the result, and attaching components on the assembly line, we create instances of the CarDoor class by initializing properties in the constructor and attaching functions to the instance. The ability to created classes that model objects found in the real world is a fundamental feature of object-oriented programming.

class CarDoor {
    constructor(
        private maximumPosition: number,
        public color: string,
        public position: number
    ) { }
 
    isDoorFullyOpen(): boolean {
        if (this.position === this.maximumPosition) {
            return true;
        }
 
        return false;
    }
  
    openDoor(degrees: number): number {
        const newPosition = this.position + degrees;
  
        if (degrees < 0) {
            throw new Error('Open door increases the position of ' +
                'the door. Please use a value greater than zero.');
        }
  
        if (newPosition > this.maximumPosition) {
            throw new Error('The door\'s position must be less ' +
                `than ${this.maximumPosition} degrees`);
        }
  
        this.position = newPosition;
        return this.position;
    }
}
  
const myDoor = new CarDoor(45, 'red', 0);
 
// Compile error: maximumPosition is private an only accessible
// within class 'CarDoor'.
myDoor.maximumPosition = 50;

By defining CarDoor as a class instead of an interface, we are able to: 1.Create properties and functions that can only be accessed by the object itself. 2.Control how an instance of the class is initialized.

In the example, notice that maximumPositionDegrees is a private property set in the constructor of our class. Setting maximumPositionDegrees in the constructor ensures the CarDoor instance has a value for maximumPositionDegrees immediately. Furthermore, by making maximumPositionDegrees private, we limit its access to functions of the CarDoor instance. Once maximumPositionDegrees is set in the constructor, there is no way for the property to be accessed or set outside the context of the instance’s own functions. While we could set maximumPositionDegrees after instantiation or make it publically available for modification, this wouldn’t model reality—a car door is not easily modified to swing open more or less. The CarDoor class allows us to more effectively model such constraints.

Interface or Class?

As you discover more about classes and interfaces, you will learn that the two concepts don’t exist in isolation but instead complement one another. In our car door example, we saw that the door was more effectively modeled using a class. But car doors share common elements with all types of doors, namely that they can be open or closed. To enforce this property of doors, we can define a Door interface with functions open and close. Our CarDoor class could then implement this interface. By implementing the Door interface, consumers of the CarDoor class know that, at a minimum, a CarDoor instance can be opened and closed.

interface Door {
    position: number;
 
    openDoor(degrees: number): number;
    closeDoor(degrees: number): number;
}
 
class CarDoor implements Door {
    constructor(
        private maximumPosition: number,
        private minimumPosition: number,
        public color: string,
        public position: number
    ) {}
 
    openDoor(degrees: number): number {
        const newPosition = this.position + degrees;
 
        if (degrees < 0) {
            throw new Error('Open door increases the position of ' +
                'the door. Please use a value greater than zero.');
        }
 
        if (newPosition > this.maximumPosition) {
            throw new Error('The door\'s position must be less ' +
                `than ${this.maximumPosition} degrees`);
        }
 
        this.position = newPosition;
        return this.position;
    }
 
    closeDoor(degrees: number): number {
        const newPosition = this.position + degrees;
 
        if (degrees > 0) {
            throw new Error('Close door decreases the position of ' +
                'the door. Please use a value less than zero.');
        }
 
        if (newPosition < this.minimumPosition) {
            throw new Error('The door\'s position must be less' +
                `than ${this.minimumPosition} degrees`);
        }
 
        this.position = newPosition;
        return this.position;
    }
}
 
const myDoor = new CarDoor(45, 0, 'red', 0);

There are additional features of TypeScript that offer a middle ground between interfaces and classes. Abstract classes and inheritance allow us to define some properties and functions of a class while leaving others to be defined later by classes that inherit the abstract class. There are also special types that have properties of interfaces but are used in specific situations. These features are beyond the scope of this article, but you can find documentation on advanced types on the Typescript website.

Conclusion

Interfaces define the shape an object must have while leaving implementation up to the object. Classes define not only the shape of the object but also how properties of object can be accessed and modified. To decide whether to create a class or an interface, consider whether your needs are limited to enforcing the shape of an object. If so, you can utilize an interface. If you also need control over how the object is initialized and how its properties and functions interact, use a class.

TypeScript