Classes over interfaces in TypeScript

I've been working with several TypeScript projects, and I've noticed a common practice to treat interfaces only as a data bucket, e.g., in a REST API response. This usually leads to dirty code, and we have to spend a lot of time refactoring simple code in many places and there's a high risk that we will miss conditions. That’s why, in this tutorial, I will show you how to utilize classes for your advantage in TypeScript projects.

First things first: basic information about classes and interfaces

Particular data structures have their use cases and we have to know when it is best to apply each of them. The class will work where we need a rigid connection of business logic with our data structure, and this data structure will always provide one solution to the problem we want to solve. However, the interface would be perfect when we need more flexibility and know that the solution can change over time or the problem can be solved in several ways. The interface provides low coupling and allows us to create a new solution to the existing problem.

Interfaces and classes in TypeScript: definitions and use casesTermDefinitionUse cases Interface The interface is a virtual structure that only exists in TypeScript. It is used only for type-checking purposes. The interface defines the properties and methods the class instance can have.

You can use interfaces to:

  • Create an abstraction layer, e.g., for an external library in your application,
  • Ensure contract in each instance which uses the interface,
  • Object as a parameter.

Class Class is an object factory. It’s the blueprint of what the object is supposed to look like and how it is going to be implemented You would typically use classes to:

  • Build reusable code,
  • Implement business logic.

Why putting code logic into classes over interfaces matters

While you can use interfaces only as data bucket, you run the risk of having hard-maintainable code. This, in turn, leads to a state called "shotgun surgery" and is often related to the anemic model. The anemic model is when the object doesn't contain the business logic like validation, calculation, etc.

Also, code with the anemic model looks more procedural than object-oriented. Shotgun surgery is a kind of code smell. It smells like there is something wrong with your code! In such a case, you have to do a single change in multiple places, hence the name: shotgun surgery. Like with a shotgun, you will hit many places instead of the one you need. To avoid that, it pays off to use class-based logic.

All too often, developers forget that they can provide some behavior inside their data structure and get rid of implicit complexity.

Here are two examples of how you can avoid entangling the code unnecessarily.

// example api response
// GET https://pokeapi.co/api/v2/pokemon/bulbasaur
{
"name": "bulbasaur",
"types": [
{
"type": {
"name": "grass"
}
},
{
"type": {
"name": "poison"
}
}
]
}// incorrect example
import axios from "axios";

interface Pokemon {
name: string;
types: Array<{
type: {
name: 'poison' | 'grass';
};
}>;
}

async function fetchPokemon({pokemonName}: { pokemonName: string }): Promise<Pokemon> {
const response = await axios.get(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);
return response.data;
}

function poisonAttack(pokemon: Pokemon) {
const hasPoison = pokemon.types.some(({type}) => type.name === "poison");
if (!hasPoison) {
return;
}

console.log(`${pokemon.name} has poisoned you!`);
}

async function main() {
const bulbasaur = await fetchPokemon({pokemonName: 'bulbasaur'});

if (bulbasaur.types.some(({type}) => type.name === "poison")) {
console.log('Pokemon can poisons you!');
}

await poisonAttack(bulbasaur);
}

main();

What we can see is that I used the “some()” method on bulbasaur two times. If we decide to modify which types of the Pokémon class can poison you, we will have to change our code in two places. Now, I will show you how we can prevent code modification in many places.

// correct example
import axios from "axios";

class Pokemon {
name: string;
types: Array<{
type: {
name: 'poison' | 'grass';
};
}>;

constructor(data: Partial<Pokemon>) {
Object.assign(this, data);
}

hasPoison(): boolean {
return this.types.some(({type}) => type.name === "poison");
}

poisonAttack(): void {
if (!this.hasPoison()) {
return;
}

console.log(`${this.name} has poisoned you!`)
}
}

async function fetchPokemon({pokemonName}: { pokemonName: string }): Promise<Pokemon> {
const response = await axios.get(`https://pokeapi.co/api/v2/pokemon/${pokemonName}`);
return new Pokemon(response.data);
}

async function main() {
const bulbasaur = await fetchPokemon({pokemonName: 'bulbasaur'});

if (bulbasaur.hasPoison()) {
console.log('Pokemon can poisons you!');
}

await bulbasaur.poisonAttack();
}
main();

As we can see, instead of a pure interface, I used a class with methods which are responsible for doing the same thing as in the example above. Thanks to moving the logic into a class, if we decide to make some changes in the logic, we will have to make a change in only one place!

MERIX TIP! 💡

Why is moving the logic into a class important in the context of clean TypeScript code?

Moving the logic into a class allows you to execute changes in the class-based code in a quicker way (instead of having to modify multiple code lines for the same reason). As a bonus, you get to conduct tests in an easier way. A way to go, isn’t it?

Also, by using an instance of the Pokémon class, you ensure the data from REST API is correct each time. What you have to do is add a validation inside the Pokémon constructor. Here is an example of how you can do that: 

class Pokemon {
name: string;
types: Array<{
type: {
name: 'poison' | 'grass';
};
}>;

constructor(data: Partial<Pokemon>) {
this.validate(data);
Object.assign(this, data);
}

validate(data: Partial<Pokemon>) {
const validationSchema = Joi.object({
name: Joi.string(),
types: Joi.array().items(
Joi.object({
type: Joi.object({
name: Joi.string().valid('poison', 'grass')
}).unknown()
}).unknown()
)
}).unknown();
const {error} = validationSchema.validate(data);

if (error) {
throw new Error(error.message);
}
}
// rest of previous code
}

As you can see, I added a validation inside the constructor. Now, I am checking if the incoming data has the correct structure and if it has the correct types as well.

If you need the code repo, feel free to use this one: classes over interfaces in TypeScript. All caught up in TypeScript topics? Check out also this piece about working with TypeScript in React!

Using classes in your TypeScript projects shouldn’t be an afterthought

When structuring your code’s logic into classes, the sacrifice you’re making (not taking the easy path) might actually be worth the initial effort in the long run. Working with a cleaner code, you stand to gain the precious time that would otherwise be spent on refactoring later on, not to mention the lower risk of omitting some conditions. A way to go, isn’t it?

Recommended further reading:

Want to work with TypeScript? Check out our Node.js career opportunities!

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .