TypeScript: Classes vs Interfaces

We have had classes available to us natively in JavaScript for a little while now, and they have been around in TypeScript for even longer. In TypeScript, however, we also have the concept of an interface, and the question often arises when adding type annotations to certain parts of our code:

“Should I be using an interface or a class for this type annotation?”

This article is going to focus on how interfaces compare to classes in TypeScript, so that we can answer that very question!

Our Example

Let’s start off with an example in order to focus in on what we are trying to understand in this post:

fetch('https://jameshenry.blog/foo/bar')
    .then((response) => {
        console.log(response.status) // Some HTTP status code, such as 200
    })

This is a very contrived form of a common task required when building UIs - fetching data from a remote server, and then using that data in our frontend code.

If we let TypeScript take a look at this code as it is now, it would be forced to infer the type of the response parameter as any. There is no way for it to know, just by analysing the code, what the type should be.

At this point, to increase the type safety of our program, we would want to add our own explicit type annotation to response, in order to tell the TypeScript compiler what we believe the type should be:

// `Response` will be defined here...

fetch('https://jameshenry.blog/foo/bar')
    .then((response: Response) => {
        console.log(response.status)
    })

Now we have reached the central question that motivated this blog post… Should our new Response type be defined as an interface or a class?

What is an interface?

When TypeScript checks the types of the various parts of our program, one of the key approaches it uses is so-called “duck typing”.

“If it looks like a duck, and quacks like a duck, it’s a duck.”

In other words, we are determining if something can be classified as a particular type by looking at whether or not it has the required characteristics/structure/shape. Let’s call it “shape” from now on.

In TypeScript, an interface is a way for us to take this particular shape and give it a name, so that we can reference it later as a type in our program.

Let’s take the duck analogy, and actually make an interface for it:

// A duck must have...
interface Duck {
    // ...a `hasWings` property with the value `true` (boolean literal type)
    hasWings: true
    // ...a `noOfFeet` property with the value `2` (number literal type)
    noOfFeet: 2
    // ...a `quack` method which does not return anything
    quack(): void
}

From now on in our TypeScript code, if we want to make sure something is a duck (which really means, it “implements our Duck interface”), all we need to do is reference its type as Duck.

// This would pass type-checking!
const duck: Duck = {
    hasWings: true,
    noOfFeet: 2,
    quack() {
        console.log('Quack!')
    },
}

// This would not pass type-checking as it does not
// correctly implement the Duck interface.
const notADuck: Duck = {}
// The TypeScript compiler would tell us
// "Type '{}' is not assignable to type 'Duck'.
// Property 'hasWings' is missing in type '{}'."

Now we understand how interfaces can help TypeScript catch more potential issues in our code at compile time, but there is one more critical feature of interfaces that we need to keep in mind:

An interface is only used by TypeScript at compile time, and is then removed. Interfaces do not end up in our final JavaScript output.

Let’s complete the section on interfaces by finally defining our dead simple Response type as an interface:

interface Response {
    status: number // Some HTTP status code, such as 200
}

fetch('https://jameshenry.blog/foo/bar')
    .then((response: Response) => {
        console.log(response.status)
    })

If we now run this through the TypeScript compiler, we get a program which compiles with no errors (as long as we are in an environment which defines the DOM’s fetch API), and the outputted JavaScript will be the following:

fetch('https://jameshenry.blog/foo/bar')
    .then(function (response) {
    console.log(response.status);
});

We can see that our extra type information at compile time has had no impact on our program at run time! The magic of TypeScript interfaces!

Using a Class

Let’s now take our example and redefine our Response type as a class instead of an interface:

class Response {
    status: number // Some HTTP status code, such as 200
}

fetch('https://jameshenry.blog/foo/bar')
    .then((response: Response) => {
        console.log(response.status)
    })

As we can see, in this case it is simply a matter of changing interface to class, and if we pass our code through the TypeScript compiler, we will still have the exact same level of type-safety and produce no compile time errors! So far, the behaviour is identical.

The real difference comes when we consider our compiled JavaScript output.

Unlike an interface, a class is also a JavaScript construct, and is much more than just a named piece of type information.

The biggest difference between a class and an interface is that a class provides an implementation of something, not just its shape.

Here is our updated output from the TypeScript compiler, after changing our interface to a class:

var Response = (function () {
    function Response() {
    }
    return Response;
}());
fetch('https://jameshenry.blog/foo/bar')
    .then(function (response) {
    console.log(response.status);
});

Very different! As we can see, our class is being transpiled into its ES5-compatible function form, and is now an unnecessary part of our final JavaScript application. If we had a large application, and repeated this pattern of using classes as model type annotations, then we could end up adding a lot of extra bloat to our users’ bundles.

Conclusion and Further Reading

If we are looking to create types for model data coming from a remote server, or other similar sources, it is a great idea to start by using an interface.

Unlike classes, interfaces are completely removed during compilation and so they will not add any unnecessary bloat to our final JavaScript code.

If we need to codify implementations of our interfaces later on, it is very easy to turn them into full classes.

In future blog posts we will take a look at special “abstract classes”, and the usage of the implements keyword with both interfaces and classes (including using them together).

The posts will be linked here as soon as they are published, and you can also sign up to receive updates, or follow me on twitter.

Onwards!

James Henry's Picture

About James Henry

James is a passionate Senior Frontend Engineer who enjoys writing about development, software and open-source technologies.

A JavaScript expert and TypeScript evangelist, he regularly speaks at meetups and conferences across Europe.

James is a member of the ESLint, Babel and Prettier teams.

Comments