Classes and interfaces are powerful structures that facilitate not just object-oriented programming but also type-checking in TypeScript. A class is a blueprint from which we can create objects that share the same configuration - properties and methods. An interface is a group of related properties and methods that describe an object, but neither provides implementation nor initialisation for them.
Once you’re finished, check out my other article on TypeScript Interfaces vs Types!
Since both of these structures define what an object looks like, both can be used in TypeScript to type our variables. The decision to use a class or an interface truly depends on our use case: type-checking only, implementation details (typically via creating a new instance), or even both! We can use classes for type-checking and the underlying implementation - whereas we cannot with an interface. Understanding what we can get from each structure will easily let us make the best decision that will enhance our code and improve our developer experience.
Table of contents
Using TypeScript class
ES6 introduced class
officially to the JavaScript ecosystem. TypeScript boosts JavaScript classes with extra power such as type-checking and static
properties. This also means that whenever we transpile our code to whatever target JavaScript of our choice, the transpiler will keep all of our class
code present in the transpiled file. Hence, classes are present throughout all the phases of our code.
We use classes as object factories. A class defines a blueprint of what an object should look like and act like and then implements that blueprint by initialising class properties and defining methods. Therefore, when we create an instance of the class, we get an object that has actionable functions and defined properties. Let’s look at an example of defining a class named PizzaMaker
:
class PizzaMaker {
static create(event: { name: string; toppings: string[] }) {
return { name: event.name, toppings: event.toppings };
}
}
PizzaMaker
is a simple class. It has a static
method called create
. What makes this method special is that we can use it without creating an instance of the class. We just invoke the method on the class directly - much like we would with something like Array.from
:
const pizza = PizzaMaker.create({
name: 'Inferno',
toppings: ['cheese', 'peppers'],
});
console.log(pizza);
// Output: { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
Then, PizzaMaker.create()
returns a new object - not a class - with a name
and toppings
properties defined from the object passed to it as argument.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
If PizzaMaker
did not define create
as a static
method, then to use the method we would need to create an instance of PizzaMaker
:
class PizzaMaker {
create(event: { name: string; toppings: string[] }) {
return { name: event.name, toppings: event.toppings };
}
}
const pizzaMaker = new PizzaMaker();
const pizza = pizzaMaker.create({
name: 'Inferno',
toppings: ['cheese', 'peppers'],
});
console.log(pizza);
// Output: { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
We get the same output we had with create
as a static
method. Being able to use TypeScript classes with and without an existing instance of a class makes them extremely versatile and flexible. Adding static
properties and methods to a class makes them act like a singleton while defining non-static properties and methods make them act like a factory.
Now, unique to TypeScript is the ability to use classes for type-checking. Let’s declare a class that defines what a Pizza
looks like:
class Pizza {
constructor(public name: string, public toppings: string[]) {}
}
In the Pizza
class definition, we are using a handy TypeScript shorthand to define class properties from the arguments of the constructor - it saves a lot of typing! Pizza
can create objects that have a name
and a toppings
property:
const pizza = new Pizza('Inferno', ['cheese', 'peppers']);
console.log(pizza);
// Output: Pizza { name: 'Inferno', toppings: [ 'cheese', 'peppers' ] }
Aside from the Pizza
name before the pizza
object that shows that the object is in fact an instance of the Pizza
class, the output of new Pizza(...)
and PizzaMaker.create(...)
is the same. Both approaches yield an object with the same structure. Therefore, we can use the Pizza
class to type-check the event
argument of PizzaMaker.create(...)
:
class Pizza {
constructor(public name: string, public toppings: string[]) {}
}
class PizzaMaker {
static create(event: Pizza) {
return { name: event.name, toppings: event.toppings };
}
}
We’ve made PizzaMaker
much more declarative, and hence, much more readable. Not only that, but if we need to enforce the same object structure defined in Pizza
in other places, we now have a portable construct to do so! Append export
to the definition of Pizza
and you get access to it from anywhere in your application.
Using Pizza
as a class is great if we want to define and create a Pizza
, but what if we only want to define the structure of a Pizza
but we’d never need to instantiate it? That’s when interface
comes handy!
Using TypeScript interface
Unlike classes, an interface
is a virtual structure that only exists within the context of TypeScript. The TypeScript compiler uses interfaces solely for type-checking purposes. Once your code is transpiled to its target language, it will be stripped from its interfaces - JavaScript isn’t typed, there’s no use for them there.
And, while a class may define a factory
or a singleton
by providing initialisation to its properties and implementation to its methods, an interface
is simply a structural contract that defines what the properties of an object should have as a name and as a type. How you implement or initialise the properties declared within the interface
is not relevant to it. Let’s see an example by transforming our Pizza
class into a Pizza
interface:
interface Pizza {
name: string;
toppings: string[];
}
class PizzaMaker {
static create(event: Pizza) {
return { name: event.name, toppings: event.toppings };
}
}
Since Pizza
as a class or as interface is being used by the PizzaMaker
class purely for type-checking, refactoring Pizza
as an interface did not affect the body of the PizzaMaker
class at all. Observe how the Pizza
interface just lists the name
and toppings
properties and gives them a type. What also changed is that we cannot create an instance of Pizza
anymore. Let’s further explain this core difference between interface
and class
by considering Pizza
as a class
again.
Using TypeScript class vs using Typescript interface
As it is, our current code provides type-checking for Pizza
but can’t create a pizza:
interface Pizza {
name: string;
toppings: string[];
}
class PizzaMaker {
static create(event: Pizza) {
return { name: event.name, toppings: event.toppings };
}
}
This is unfortunate because we are missing a golden opportunity to further improve the declarative nature and readability of our code. Notice how PizzaMaker.create()
returns an object that surely looks a lot like a Pizza
would! It has a name
that is a string
and it has toppings
that is a string
array - we infer the property types from the type of event
which is Pizza
. Wouldn’t it be awesome if we could return an instance of Pizza
from within PizzaMaker.create()
?
As mentioned many times earlier, we can’t instantiate the Pizza
interface, doing so will trigger an error. However, we can refactor again Pizza
to be a class and then return an instance of Pizza
:
class Pizza {
constructor(public name: string, public toppings: string[]) {};
}
class PizzaMaker {
static create(event: Pizza) {
return new Pizza(event.name, event.toppings);
}
}
const pizza = PizzaMaker.create({ name: 'Inferno', toppings: ['cheese', 'peppers'] };
We enforce the structure that the event
argument of PizzaMaker.create()
takes whilst still being able to create the object that the type Pizza
as a class defines! We get the best of both worlds here - the blueprint and the contract. It’s up to you which one you need for your use cases.
Learn about TypeScript Interfaces vs Types next!
Conclusion
We’ve learned a lot, without really diving into a huge amount of code. The tl:dr; is if you need/wish to create an instance of perhaps a custom object, whilst getting the benefits of type-checking things such as arguments, return types or generics - a class makes sense. If you’re not creating instances - we have interfaces at our disposal, and their benefit comes from not generating any source code, yet allowing us to somewhat “virtually” type-check our code.
If you are serious about your TypeScript skills, your next step is to take a look at my TypeScript courses, they will teach you the full language basics in detail as well as many advanced use cases you’ll need in daily TypeScript development!
Since both an interface and a class define the structure of an object and can be used interchangeably in some cases, it’s worth noting that if we need to share structural definition amongst various classes, we can define that structure in an interface and then have each class implement that interface! Each class then will have to declare or implement each property of the interface. That’s the power of TypeScript, and it’s also super flexible. We have comprehensive object-oriented design paired with versatile type-checking.