Register to get access to free programming courses with interactive exercises

Liskov substitution principle JS: Dive into Classes

Technically speaking, method overriding has no restrictions in any way. The descendant class can change the behavior of any method as much as possible. On the one hand, it opens up a lot of freedom to do what you want. However, some changes can entail architectural problems. The most significant of these is broken polymorphism.

Let's look at an example. Suppose we decided to write a logger — an object that logs messages:

// Definition
class Logger {
  // Code
}

// Usage
const logger = new Logger();
logger.log('debug', 'Doing work');
logger.log('info', 'Useful for debugging');

The logger allows you to write messages with different levels of importance, ranging from debug to emergency. The signatures of the log() method are arranged in such a way so that the first parameter is always the level of the message, and the second is the message itself.

The message is a string of any format, and the level can be one of 8 options.

The importance level allows you to change the output settings. For example, in the development stage, we see everything, including debug messages. Meanwhile, in the production stage, we show only serious errors so as not to pollute the logs.

Suppose we don't like it and decide to change the signature so that we can pass the level as the second parameter. It will allow us to set the most often encountered level in the application as the default level. To do this, let's create a descendant class MyLogger:

class MyLogger extends Logger {
   // Here we implement log's new signature
   log(message, level = 'debug') {
     return super.log(level, message)
   }
}

// Usage
logger.log('Doing work'); // By default debug
logger.log('Useful for debugging', 'info');

What's wrong with this code? Changing the signature like this makes polymorphism impossible. These classes are incompatible with each other:

// Suppose a system component wants to work with a Logger, but MyLogger is passed to it
const logger = new MyLogger();
database.setLogger(logger);

database.doSomething();
// The logger is called internally
// logger.log('info', 'boom!');

This code will not work correctly (or will end with an error) because the database object will use the logger as required by Logger. It is contrary to the way MyLogger works.

In 1987, Barbara Liskov formulated the Liskov Substitution Principle (LSP), which allows us to build type hierarchies properly:

Let q(x) be a property that's true for objects x of type T. Then q(y) must also be true for objects y of type S, where S is a subtype of type T

It sounds too mathematical. Many developers have tried to reformulate this rule to make it intuitive. The easiest formulation sounds like this:

Functions that use a base type should be able to use subtypes of the base type without knowing what they are

In the above example, the setLogger(logger) function expects an object that matches the signature of the Logger methods, but we passed it MyLogger, which doesn't follow the original signature. According to the principle, the code should continue to work as if nothing had happened, but this doesn't happen because of the interface violation.

JavaScript is a language with duck typing, so the type's concept is not explicit. Interfaces are called types in this case. We can find them in languages such as PHP/Java/C++/TypeScript. In JavaScript, type exists at a logical level, meaning in our heads. Think of a type as a signature of methods of a particular class. If two classes have the same methods (by name and signature), they are polymorphic to that method or the set of methods.

If you're curious, think about this: Why was this principle needed in the first place? Why not leave this job to the language? Unfortunately, it's technically impossible to verify compliance with the Liskov principle. Therefore, its implementation is passed to developers.

Design rules for type hierarchies

There are a few rules to keep in mind when working with types:

  • We cannot strengthen preconditions in subclasses
  • We cannot weaken postconditions in the subclasses
  • We should consider the historical limitations

Preconditions are constraints on input data, and postconditions are constraints on output data. Moreover, there are limitations of type systems. Due to them, we cannot describe many conditions at the signature level. You either have to describe them in plain text or add checks to the code (contract design).

For example, in our logger, the precondition is that the log() method takes one of 8 message levels as the first parameter. The Liskov principle states that we cannot create a class that implements this interface (logically) that can handle fewer levels.

We call it strengthening preconditions, meaning the requirements become stricter — like five levels instead of eight.

Attempting to use an object of such a class will end with an error when a system tries to pass a level that isn't supported. And it doesn't matter whether it leads to an error (exception) or the logger silently eats the message without logging it. The main thing is that the behavior has changed.

There are situations when developers don't see the cause of this behavior, and they start looking at the consequences. They add type checks in places where we use these objects. This kills the polymorphism.

The situation with postconditions is similar, but vice versa. It is ok if the method returns a truncated set of values since this set is still within the requirements of the (again, virtual) interface. But you can't extend the return because there are values that the interface didn't foresee. It also applies to exceptions.

And finally, historical constraints. Subtypes (in the case of JS – descendant classes) cannot add new methods to change (mutate) the data of the base type (with JS classes). The way we change the properties is defined in the base type by that type.


Recommended materials

  1. Circle-ellipse problem

Are there any more questions? Ask them in the Discussion section.

The Hexlet support team or other students will answer you.

About Hexlet learning process

For full access to the course you need a professional subscription.

A professional subscription will give you full access to all Hexlet courses, projects and lifetime access to the theory of lessons learned. You can cancel your subscription at any time.

Get access
130
courses
1000
exercises
2000+
hours of theory
3200
tests

Sign up

Programming courses for beginners and experienced developers. Start training for free

  • 130 courses, 2000+ hours of theory
  • 1000 practical tasks in a browser
  • 360 000 students
By sending this form, you agree to our Personal Policy and Service Conditions

Our graduates work in companies:

<span class="translation_missing" title="translation missing: en.web.courses.lessons.registration.bookmate">Bookmate</span>
<span class="translation_missing" title="translation missing: en.web.courses.lessons.registration.healthsamurai">Healthsamurai</span>
<span class="translation_missing" title="translation missing: en.web.courses.lessons.registration.dualboot">Dualboot</span>
<span class="translation_missing" title="translation missing: en.web.courses.lessons.registration.abbyy">Abbyy</span>
Suggested learning programs
profession
Development of front-end components for web applications
10 months
from scratch
Start at any time

Use Hexlet to the fullest extent!

  • Ask questions about the lesson
  • Test your knowledge in quizzes
  • Practice in your browser
  • Track your progress

Sign up or sign in

By sending this form, you agree to our Personal Policy and Service Conditions
Toto Image

Ask questions if you want to discuss a theory or an exercise. Hexlet Support Team and experienced community members can help find answers and solve a problem.