Back to the homepage
Angular

Angular & Liskov Substitution Principle

Introduction

This is the third article in the series on the acronym SOLID. It is a set of rules thanks to which we can write code that will be easier for us to scale, and change the behavior of our application, without moving the code of a large part of the app.

The set of rules consists of

  • Single Responsibility Principle,
  • Open/Closed Principle,
  • Liskov Substitution Principle,
  • Interface Segregation Principle,
  • Dependency Inversion Principle.

 

Today we will focus on the Liskov Substitution Principle 🙂

Liskov Substitution Principle

The Liskov principle is, in my opinion, the most complex rule that makes up SOLID. Its formal definition is as follows:

 

If S is a subtype of T, then objects of type S can be used in place of objects of type T,

and the program will retain its correctness. To illustrate this better, let’s look at the image below:

Credit: https://devexperto.com/principio-de-sustitucion-de-liskov/principio-sustitucion-liskov-meme/

To sum up,  if something looks like a duck, behaves like a duck, but needs batteries to work, then we probably have a bad abstraction and modeled the type relationship in the wrong way. Let’s look at such pseudocode:

We have a type of “duck” that can make sounds (quack).

Now let’s create the subtype of duck created by us, an electric duck (on batteries):

Supposedly, we used an ordinary duck type everywhere in our code, but we used a newly defined electric duck in one place. Out of habit to an ordinary duck, we forgot to initiate it with batteries. As a result, our code does not work (an exception is thrown), and the duck does not make a sound. This is precisely a violation of the Liskov rule.

Let’s ask what could a correct relation between types look like? We could create a male duck and a female duck. Both would make sounds, regardless of gender. Objects in a derived class should complement, not replace, the behavior of the base class.

It seems that following this rule is difficult. Fortunately, the authors of this rule have collected the conditions that must be met for it to remain intact.

Covariance of output types

The first condition is the covariance of the output types.

Covariance is a conversion from a more general type to a more specific one, e.g. from the Car to the Rolls-Royce.

Let’s look at an example:

In the first case, the implementation of the Mapper function breaks the covariance of the output types, because the argument types are extended (the method returns not only a string but also a number).

In the second case, everything is fine, because the method narrows the returned types (returns the someString type instead of the string).

Countervariance of input types

The second condition is the contravariance of input types.

Contravarianism is the opposite of covariance. It is a conversion from a more specific to a more general type, e.g. from the Rolls-Royce to the Car.

Let’s look at an example:

In the first case, the implementation of the Mapper function breaks the contravariance of the input types because the argument types are narrowed (the method only accepts the string type).

In the second case, everything is fine, because the method extends the accepted types (it additionally takes an array of values of type string).

Exceptions

Another condition is exceptions. If the subtype S (Derived) introduces new exceptions, they must be a subtype of exceptions thrown in the general type T (Base).

Let’s look at such code:

Here’s the problem – the Derived subtype throws an exception that is not an exception subtype from the general Base type.

The solution is to bind the typing relationship to the exceptions thrown by the subtype.

Contracts – prerequisites

Another condition is the so-called precondition contracts.

The definition is: A subtype cannot be pickier than a Base base type, it must be able to handle at least the same range of data.

Let’s say we have a library system. The following code of services that are used to calculate the fee for borrowed books:

The first one of them is used to calculate the fee for ordinary users. After borrowing one book, he must pay a rental fee. The second service (Derived) is used to calculate the fee for VIP users.  The VIP user pays a subscription periodically, which is why he pays the rental fee only from the 4th borrowed book.

It can be seen that the service for VIP users is unable to handle the situation where the number of books is less than 4. This is a violation of the prerequisites. We can fix this by simply returning a value of 0 for fewer books (which is in line with business logic – the fee, in this case, should be 0):

Contracts – final terms

Another condition is the so-called contracts regarding final conditions.

The definition is: the subtype must return data that does not break the conditions imposed on the data returned by the Base base type.

Let’s look at the code:

Let’s say we’re still in the library app. We have these 2 methods that refund the amount of the rental fee. As you can see, the method in the base will always return the value of min. 10. On the other hand, the method in Derived (for VIP users), will return a minimum of 0. In this case, we have a violation of the end conditions – the Derived subtype breaks the condition (fee amount > = 10) for the data set by the base type Base. Improving this may require consulting the business logic (sometimes we can agree to break the Liskov rule consciously).

Features – invariant behavior

Another condition is the preservation of the invariant. What does that mean? What is an invariant?

Let’s start with an example. For example.:

  • the user always has a name and surname,
  • the rectangle always has side A and side B.

An invariant is something inviolable, something that is the essence of a given type.

A more formal definition of an invariant is:

a function from a set of class states into a set of {true, false}.

So, in summary, an invariant is a function that allows us to determine whether the state of an object is legal or not.

To maintain the invariant, we must ensure that the condition of the object is legal.

Features – the principle of history

Another condition is to follow the rules of history. What is the principle of history?

As in the previous section, let’s start with an example.

The invariant is the sentence: the size of the structure is always less than the maximum size.

In this case, the principle of history says that the maximum size of the structure does not change.

A more formal definition of the principle of history:

a function from a set of pairs of states into a set of {true, false}.

It is a function that allows us to determine whether the transition from state A to state B is legal or not.

To follow the rule of history, we must ensure that transitions between states of the object are legal.

Summary

Liskov Substitution Principle is the most complex rule in the SOLID set. To comply with it, one sentence should be remembered above all: objects of a derived type should complement, not replace, the behaviour of the object of the base type. The rest of the conditions help to check whether we keep the Liskov principle in our code.

In the next article, we will focus on the Interface Segregation Principle 🙂

About the author

Wojciech Janaszek

I am an Angular and NestJS developer. I started my adventure with the first versions of "new" Angular (2 and up). Recently I'm also using NestJS (which is easy to learn if you know Angular well - everything is very similar). In my work I pay a lot of attention to so called clean code and clean architecture. I like to have "order" in the code :) In my free time I am interested in sports cars, motorsport in general. I also play amateur volleyball.

Do you want to write articles for our blog together with us?
Join us and create valuable content for Angular fans with Angular.love!

Leave a Reply

Your email address will not be published. Required fields are marked *