LSP (short for Liskov Substitution Principle, not for Language Server Protocol) is probably one of the least understood concept of the SOLID principles. So in this post, I want to actually address the complete principle, not just what people know.

Because of the “typed” nature of the LSP, I’ll try to use a language that people are familiar with, and that I can show in a playground somewhere: Typescript. Truth to be told, I am not a Typescript developer (nor do I actually like the language) so my code might be a little weird. Anyway, moving on:

What people know

The actual text is

Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.

This is actually hard to read, so people simplify, usually, with:

Objects of a superclass shall be replaceable with objects of its subclasses without changing behavior

Which is correct, but incomplete.

Inheritance

“Superclass” and “Subclass” are usually concepts present in object oriented programming languages, but there is a catch (a very big catch): first, not all object oriented programming languages are the same; for all matters and purposes, dynamic languages like Ruby and Javascript allows you to honor the LSP without actually needing to subclass something; the second problem is that not all static typed, object-oriented programming languages actually agree on what is a superclass or subclass.

The second point might be weird, so let’s start with subtypes (not subclasses, for now). Suppose I have the following classes definitions:

class Phone {}
class Android extends Phone {}
class IOS extends Phone {}

So far, so good. An Android phone is not an IOS phone, and vice-versa. Most, if not all, static languages will agree that Android is a subclass of Phone. Now, let’s make an Array of Androids:

let androids: Array<Android> = [new Android()];
let phones: Array<Phone> = androids;

Now… is an Array of Androids also an Array of Phones? That’s… where things get weird. But first, a small sidetrack.

Functions

In languages that support first-class functions like Typescript, Scala, F#, etc, a function have a type; but because a function is also a “thing”, it can be passed around, and you need to be aware on how the types work with functions. So, let’s look at an example:

function android2phone(android: Android): Phone { ... }
function android2android(android: Android): Android { ... }
function phone2phone(phone: Phone): Phone { ... }
function phone2android(phone: Phone): Android { ... }

Disregard on how these functions should actually be implemented; let’s suppose, for things that kinda don’t make too much sense, like the phone2android, that it somehow receives a “generic phone” and returns a “new phone with the Android operating system with the same specs as the original phone”, for example (a very real, possible implementation of a recommending system for stores).

Now – which one of these functions is a “subtype” or “supertype” of the other? How do we even know that? Here’s where LSP comes to help: using the formal definition (not the “interpreted one”) but simplifying a little bit: if I can prove a property for objects of type T, then the same property must be true for objects of type S where S is a subtype of T.

This is the basis for “design by contract” and it basically says that I can’t change the behavior of a program by using a subtype instead of the actual expected type. In our situation, it means that a function is a subtype if it can be used instead of the supertype. Let’s see how this works with the code – I’m going to make four functions that accept a single parameter – that parameter is another function:

function f1(fun: (_: Phone) => Phone): any
function f2(fun: (_: Android) => Phone): any
function f3(fun: (_: Phone) => Android): any
function f4(fun: (_: Android) => Android): any

And here’s the table checks which function can be passed as a parameter to the ones above:

f1 f2 f3 f4
android2phone x x x

android2android x x

phone2phone x x

phone2android

Here’s what interesting on this table: the function that have the “Superclass” as the parameter, and returns the “Subclass” is, essentially, the “supertype” of everyone. This is called “Variance”, and the actual terms are – when the types order from the most specific to the most generic then you have a covariance. When the types are from the most generic to the most specific you have contravariance. And if there’s no actual relation, it’s invariant. So, the order Android -> Phone is covariant, and Phone -> Android is contravariant.

One function is a subtype of another if their parameters are contravariant and the return type is covariant – so a function that accepts Phone and returns an Android is a subtype of a function that accepts Android and returns Phone. It’s easy to see why – suppose we want to use both functions. The first one will accept both Phones and Androids, while the second one will only accept Androids. Then, on the return type – if we’re expecting a Phone, both functions work, but if we’re expecting an Android, only the first function will work.

This is one of the most weird concepts for beginners to grasp, and multiple languages don’t agree on how this works – Typescript will accept any function for f1 to f4 – even the ones with incompatible types – they just need to be subtypes of Phone in any position; Scala, on the other hand, will not accept the ones that don’t honor the correct variance.

Parametric Polymorphism (AKA – Generics)

Still with the types we defined previously – is an Array of Androids a subclass of an Array of Phones? Typescript says so; OCAML agrees; Scala says “it depends”, and C++ says “no”. So let’s see an example in Typescript:

class Phone {}
class Android extends Phone {}
class IOS extends Phone {}

let androids: Array<Android> = [new Android()];
let phones: Array<Phone> = androids;

Typescript will happily accept the last line – downcasting an Array<Android> to an Array<Phone>. But then… we can do this:

phones.push(new IOS())
console.log(androids)

Because both point to the same object, we now have an Array of Androids that contain an… IOS?

But then, why does Scala says “it depends” and then OCAML says “yes, it is a subclass”? Because of immutability. In Scala, if you have an immutable List, then you can say that a List of Androids is, indeed, a subclass of a List of Phones. And the reason is simple – because you can’t mutate the List, you can’t add more members to it (without making a copy), so there’s no way to alter the List<Phone> in a way that breaks List<Android> (because there’s no way to alter anything, basically).

Which leaves us to the next point:

Mutability

In non-functional languages, we can change stuff. We can “mutate” the object. A Person, for example, might have a name attribute and we can have some way to change that name, and everybody that’s keeping a reference for that Person will now have a different name than what it originally was.

This is not a problem (well, it’s not a problem for object-oriented programming languages – it is a problem for functional programming languages because mutability is not allowed, or it’s restricted) but it can break the Liskov Substitution Principle. How? Well, we saw an example before, but here’s another example:

function allowedAccesses(person: Person) {
  const accessParty = person.age &gt;= 18
  const permissions = capturePermissions(person)
  const possiblePermissions = simulateChanges(person)
  const canDrink = person.age &gt;= 20
  return {accessParty, canDrink, permissions, possiblePermissions}
}

This piece of code doesn’t seem too bad, but the calls to capturePermissions and simulateChanges can be problematic. Imagine, for example, that in the class Person, we can’t change the age. Ever. Then, everything works. Now, imagine that one makes a new class called PersonFromDatabase, where you can change any attribute. This basically means person.age might vary between the first check and the second check, generating a weird situation where accessParty is false but canDrink is true – an inconsistency in the code.

For the Liskov Substitution Principle, PersonFromDatabase is not a subtype of Person because it breaks the contract (well, to be fair, it might still consider one a subtype of the other, but it’s not a “behavioural subtype” because it behaves differently than the supertype). Remember – LSP says that if I can prove a property for the supertype, it also must be true for the subtypes. In the supertype, I can prove that age is immutable, but I can’t prove the same thing for its subtype, so it’s a violation of the LSP.

Behavior

Finally, the last point. Some properties about the code are even harder to prove – one example is that “a function always terminates” or “a function never causes an exception”. The reason, again, is simple – if I have a code like phone.call(n: Number), and that I know that phone.call never raises an error, I don’t need to wrap things around in a try-catch block; but if the Android class starts to raise exceptions for that same method, I now have a LSP violation (because I can’t blindly replace the Phone with an Android).

The same is true for the conditions like “the function always terminates” – if I know that a function might cause an infinite loop, I need to check which arguments I pass to that function; if the supertype never causes an infinite loop, then the subtype also can’t.

Finally, there’s the case for conditions:

function calculateScore(survey: Survey) {
  const result = survey.getResult(2023)
  const participants = survey.participants.length
  const totalScore = survey.totalScore
  return totalScore / participants
}

Let’s look exactly at survey.getResult – there are two conditions on it; first, that it receives a year, and second, that it returns a survey with at least one participant (otherwise, the following line would be 0, and in the return line, we’ll get a “divide by zero” error). The LSP also have some rules on how preconditions and postconditions work:

  1. A precondition can’t be strengthened – that means, we can’t say that getResult needs to accept a year between some range
  2. A postcondition can’t be weakened – that means, getResult can’t return a survey with no participants
  3. Invariant can’t be weakened – that means, totalScore can’t return null, for example.
  4. Finally, we can’t throw new exceptions. That means if getResult throws an error like NoSurveyException, we can’t decide, on a subtype, to also throw a new exception like SurveyWithoutParticipantsException – unless SurveyWithoutParticipantsException is a subtype of NoSurveyException (and remember – we’re using the “behavioral subtype”, so all these rules we discussed here also are valid for the errors being thrown).

Final thoughts

LSP is hard. It really is. Some techniques (like always use immutable variables) make some of the rules easier to implement – but not all. Also, as shown here, subtypes is not only “inherit a class” – there are way more things that one needs to be aware.

Now, if all of these rules are actually useful (in the sense – they do future-proof your code, but maybe it gets harder to evolve the code and add new features) that is questionable; I, for example, prefer to add tests and “assume the risk” of my contract breaking in the future; at the same time, if you are doing something user-facing, like an API, the LSP is actually a very good rule so that we don’t break what users’ are expecting from our API.

So, here’s the full Liskov Substitution Principle, in all of its glory. The next time someone mentions it, don’t use “I can replace a superclass with its subclass” because first, this is not strictly true (for function returns, in this case), and two, it’s not what the principle says; instead, try to use “A is a subtype of B if all properties of A are also properties of B” – and then, if you want to be more verbose, add “properties being not only types and subtypes, but also mutability rules, pre and post conditions, the throwing of exceptions, and behavior like ‘the code terminates'”.

And remember – everything, in programming, is a trade-off. You’re free to not honor any of the SOLID principles if that makes a better code for that occasion, and the LSP is not an exception.