Register to get access to free programming courses with interactive exercises

Liskov substitution principle JS: Dive into Classes

Technically speaking, method overriding isn't restricted in any way. The descendant class can change the behavior of any method as much as possible. On the one hand, this may seem great, as it opens up a lot of freedom to do what you want, however, some changes can entail serious architectural problems. The most significant of these is broken polymorphism.

Let's look at an example. Suppose we decided to write our own 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 itself 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 development all messages are displayed, including debug messages, while in production only serious errors are displayed, so as not to pollute the logs

Suppose we don't like it and decide to change the signature so that the level is passed as the second parameter. This will allow us to set the level most often encountered 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, which 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 with respect to 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.

Sounds mathematical. Many developers have tried to reformulate this rule to make it intuitive. The simplest formulation sounds like this:

Functions that use a base type should be able to use subtypes of the base type without knowing 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 concept of type is not explicit. Interfaces are called types in this case. They're found in languages such as PHP/Java/C++/TypeScript. In JavaScript, type exists at a logical level, 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 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:

  • Preconditions cannot be strengthened in subclasses.
  • Postconditions cannot be relaxed in subclasses.
  • Historical constraints

Preconditions are constraints on input data, and postconditions are constraints on output data. Moreover, due to the limitations of type systems, many of these conditions cannot be described 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. This is called strengthening preconditions, that is, the requirements become stricter. E.g., 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 these objects are used. This kills the polymorphism.

The situation with postconditions is similar, but vice versa. It's fine 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. This 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 to change the properties defined in the base type is defined by that type.


Recommended materials

  1. Circle-ellipse problem

Hexlet Experts

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

From a novice to a developer. Get a job or your money back!

Frontend Developer icon
Profession
beginner
Development of front-end components for web applications
start anytime 10 months

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.