Introduction
As we all know, software requirements always change, and we as developers need to make sure that these changes don't break the existing code. For this reason, the SOLID principles were introduced in Object-Oriented design to ease this process.
The SOLID principles are a set of principles set by Robert C. Martin (Uncle Bob). These principles help us create more flexible, maintainable, and understandable software. These principles are:
Liskov Substitution Principle
Interface Segregation Principle
Dependency Inversion
After introducing the Open-Closed Principle in the previous article, in this article, we will discuss the third principle Liskov Substitution Principle (LSP) which is the L in the SOLID acronym.
Definition
Lets introduce the mathematical definition of the LSP and then jump into the details. The mathematical definition is introduced by Barbara Liskov in 1988:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.
The basic object-oriented design controls the relationship between objects using either Inheritance or Composition. Inheritance, the IS-A relationship, occurs when something IS A kind of another thing. For example, a horse IS AN animal. On the other hand, Composition, the HAS-A relationship with something else. For example, an address HAS A city related to it. The LSP brings additional constraint to the Object-Oriented Design and states that these relationships aren't sufficient and should be replaced with IS-SUBSTITUTABLE-FOR.
But what that means? Simply, it means that a supertype should be replaceable with its subtype without breaking the existing code. In other words, the supertype should behave the same as its subtype.
Having said that, how can we guarantee that replacing a supertype with a subtype has no side effects on our existing code?
LSP extends OCP
Keep in mind that the five SOLID principles are somehow related to each other. And following one principle does not ensure that you are following the others correctly.
As we will see, the LSP is extending the Open-Closed principle, and following the rules of the OCP isn't enough to ensure that your code is open for extension and closed for modifications. But your code as well has to conform to the Liskov Substitution Principle to avoid any side effects.
Below is an example that will help you better understand this point:
As you see, this example is following the OCP in a perfect way. That is, if we want to add a new employee role, all we have to do is add a new class including the new role functionality that conforms to the IEmployee
contract.
That sounds good. But let me ask you a question, how would you build the Guest
role? yes, the guest role can listPosts
but how would you deal with the login
functionality as there is no authentication for a guest employee?
I think you could respond, I can leave it blank with no functionality at all, or I can throw an unsupported exception in the login
method. Yes, these solutions are intuitive if you don't keep in mind the LSP.
Again, you could ask me, since I have fulfilled the OCP perfectly and that's the most important thing to me, then what's the problem if I violate the LSP? I think this question has an implicit correct point which is some principles are more important than others. However that said, we should not ignore a principle because it is less important.
As we already knew, the LSP is all about substitution between a supertype and a subtype without impacting the existing client code. Keep in mind this point and lets jump again into your solutions:
leaving it blank with no functionality at all: Now, your client code expects the
login
function to return an authenticated user token, what will happen if you used theGuest.login
that returns nothing? Logically, it will break your existing code, wont it?throwing an unsupported exception: Again, your existing code doesn't handle this new exception from
Guest.login
. So, as a result, using theGuest.login
will break the existing code.
Surprisingly, this design perfectly follows the OCP, however, it violates the LSP.
The LSP Rules
Unfortunately, there is no easy way to enforce this principle in your code. However, to apply this principle correctly in your code, there are two types of rules you have to follow, the Signature rules and the Behavior rules .
Although you can enforce the Signature rules using a compiled language like Java, you can't enforce the Behavior rules , instead, you need to implement your own checks to enforce a specific behavior.
Firstly, let's introduce the Signature rules:
1- Countervariance of Method Arguments : It is a conversion from a more specific to a more general type. In other words, the overridden subtype method argument has to be the same as the supertype or wider.
class SuperType {
getItem(item: string) {}
}
class SubType extends SuperType {
getItem(item: string | number) { // Wider input type
if (typeof item === 'string') {}
else if (typeof item === 'number') {}
}
}
const ins = new SuperType(); // Try to replace the SuperType with SubType
const stringInput = 'string';
ins.getItem(stringInput);
If the client code expects a string
-only argument in the SuperType
, logically, if you replaced SuperType
with SubType
which accepts string
or number
arguments (wider), the client code will notice no differences.
2- Covariance of Return Types : It is a conversion from a more general type to a more specific one. In other words, the return type of the overridden subtype method has to be the same as the supertype or narrower.
class SuperType {
getItem(): number | string {
const anyCond = true;
if (anyCond) {
return 'String output';
}
return 5;
}
}
class SubType extends SuperType {
getItem(): string {
return 'Only string output';
}
}
const ins = new SuperType(); // Try to replace the SuperType with SubType
const res = ins.getItem();
if (typeof res === "string") {
// Handle the string response
} else if (typeof res === "number") {
// Handle the number response
}
The client code has already handled a string
or number
response coming from the SuperType
. So if you replaced the SuperType
with the SubType
which returns only a string
response, logically, the client code wouldn’t break.
3- Exceptions : The subtype method has to throw the same exceptions of the supertype or narrower. In fact, this rule couldn't be enforced by all compiled languages. There are some languages that can enforce it like Java, and others that couldn't enforce it like TypeScript.
class SuperType {
getItem(itemId: number) {
if (!itemId) throw new Error("NOT_FOUND");
if (itemId <= 0) throw new Error("INVALID_INPUT");
}
}
class SubType extends SuperType {
getItem(itemId: number) {
if (!itemId) throw new Error("NOT_FOUND"); // Narrower exceptions
}
}
try {
const ins = new SuperType(); // Try to replace the SuperType with SubType
ins.getItem(-1);
} catch (error: any) {
if (error.message === "NOT_FOUND") {}
else if (error.message === "INVALID_INPUT") {}
}
Like the previous rule, as long as, the client code that depends on the SuperType
handles more exceptions, if you replaced this SuperType
with the SubType
which handles fewer exceptions, the client code would notice no difference.
Secondly, let's introduce the Behavior rules:
1- Class Invariants (Property Rule): The subtype methods have to preserve or strengthen the supertypes class invariants.
class SuperType {
// Class invariant: dayHour >= 0 && dayHour <= 23
protected dayHour: number = 12;
}
class SubType extends SuperType {
// SubType should preserve or strengthen the SuperType's invariant
setDayHour(hour: number) {
if (hour < 0 || hour > 12) { // Strengthen the SuperType's invariant
throw new Error('INVALID_INPUT');
}
this.dayHour = hour;
}
}
const ins = new SubType();
ins.setDayHour(5); // Valid. Preserve the SuperType's invariant (5 > 0 && 5 < 23)
ins.setDayHour(30); // Invalid. Not preserve the SuperType's invariant (30 > 23)
The SubType
has to maintain the same SuperType
invariants or strengthen them. Think of it, if the SubType
doesn’t maintain the same SuperType
invariants, logically, it wouldn’t be substitutable for the SuperType
and would break the client code that depends on a specific behavior from the SuperType
.
2- History Constraint (Property Rule): The subtype methods shouldn't allow a state change that the supertype doesn't allow.
class SuperType {
// Class History
// should be set once at the creation time
// shouldn't be overridden after that
protected itemId: number = 40;
constructor(itemId: number) {
this.itemId = itemId;
}
}
class SubType extends SuperType {
// Invalid method behavior
setItem(itemId: number) {
this.itemId = itemId;
}
}
const ins = new SubType(2); // Valid to be set just at the creation time
ins.setItem(5); // Invalid. Not preserve the SuperType class history (`itemId` value shouldn't be overridden)
Here, if the SubType
ignored any constraint imposed by the SuperType
, logically, this will break any client code that relies on these constraints. Therefore, SubType
couldn't be substitutable for SuperType
.
3- Preconditions (Method Rule): The subtype method should preserve or weaken the preconditions of the overridden supertype method. Here, if you weaken the condition, you relax its constraints.
class SuperType {
protected dayHour: number = 10;
setDayHour(hour: number) {
if (hour < 0 || hour > 12) { // Precondition
throw new Error('INVALID_INPUT');
}
this.dayHour = hour;
}
}
class SubType extends SuperType {
setDayHour(hour: number) {
if (hour < 0 || hour > 23) { // Weakens the precondition
throw new Error('INVALID_INPUT');
}
this.dayHour = hour;
}
}
const ins = new SuperType(); // Try to replace the SuperType with SubType
ins.setDayHour(10);
In the previous example, any client code that provides the hour
input imposed to the SuperType
condition hour < 0 && hour > 12
will logically be imposed on a wider range hour < 0 && hour > 23
from the SubType
. In other words, SubType
could be substitutable for SuperType
without any side effects.
4- Postconditions (Method Rule): The subtype method should preserve or strengthen the postconditions of the overridden supertype method.
class SuperType {
sum(a: number, b: number) {
const sum = a + b;
if (sum > 50) { // Postcondition
throw new Error('TOO_BIG')
}
return sum;
}
}
class SubType extends SuperType {
sum(a: number, b: number) {
const sum = (a + b) * 0.5;
if (sum > 30) { // Strengthens the postcondition
throw new Error('TOO_BIG');
}
return sum;
}
}
const ins = new SuperType(); // Try to replace the SuperType with SubType
ins.sum(20, 40);
Like the previous example, if the client code expects the returned value from SuperType
to have a maximum value of 50
will consequently be valid if you replaced it with a SubType
returns a value with a maximum value of 30
.
Don't be Confused like me
At first glance, you might think that the Liskov Substitution Principle is all about Inheritance, but this is not true. I preferred to dedicate a separate section for this point to stress it more because it was confusing me a lot while learning this principle. I thought that the LSP could be applied only if I used Inheritance.
In fact, the Liskov Substitution Principle has nothing to do with Inheritance. The LSP is just about Subtyping regardless this subtyping comes from Inheritance or Composition*.* Since the LSP has nothing to do with inheritance, whether or not you use inheritance is irrelevant to whether or not the LSP applies*.* Take a look at this solution on StackExchange.
So, don't tightly couple the two concepts, the LSP and Inheritance. Instead, just keep in mind the LSP if you are forced to use inheritance. Take a look again at the example of the LSP extends OCP section.
LSP Violation
Lets introduce the most common violations of LSP and try to redesign it to follow the LSP.
Type Checking:
If you're checking the type of a variable inside a polymorphic code. Have a look at the below example:
interface IEmployee {}
class Employee implements IEmployee {}
class Guest implements IEmployee {}
const employees: IEmployee[] = [];
for (const employee of employees) {
if (employee instanceof Guest) { // Checking the the employee type
doSomethingSpecificForGuest();
} else {
doAnotherThingForOtherTypes();
}
}
As you see, this loop has two different functionalities based on the employee type. But what is the problem with this implementation?
Think again, the first problem here is that any time you work with employees, you might have to perform a check to see if this employee is a Guest
type to do a specific functionality or another type to do another functionality.
The second problem, you might add new types in the future and you might have to visit everywhere this check exists to add specific behaviors to support these new types. On top of that, this obviously violates the Open-Closed Principle.
So how can we solve this problem? One solution is using the Tell, Don't Ask Principle or Encapsulation. It means, don’t ask an instance for its type and then conditionally perform a specific action, instead, encapsulate that logic in the type itself and tell it to perform an action.
Let’s apply this to the previous example:
interface IEmployee {
doSomething: () => void;
}
class Employee implements IEmployee {
doSomething() {}
}
class Guest implements IEmployee {
doSomething() {}
}
const employees: IEmployee[] = [];
for (const employee of employees) {
employee.doSomething(); // Don't ask for the type, encapsulate the behavior, then tell the instance to do an action
}
Null Checking:
It has the same behavior as type checking. Have a look at the below example, instead of checking on Guest
type, you are checking on null
value like this if (employee === null)
. Obviously, this violates the LSP as well.
type User = {
id: number;
name: string;
};
interface IEmployee {
getUser: () => User;
}
class Employee implements IEmployee {
getUser(): User {
return { id: 1, name: "test" };
}
}
const employees: IEmployee[] = [new Employee(), null, null, new Employee()];
for (const employee of employees) {
let user = null;
if (employee === null) { // Null checking to do specific behavior
user = { id: -1, name: "Not exist user" };
} else {
user = employee.getUser();
}
console.log(user);
}
But how can solve this problem? One common solution for this problem is using the Null Object design pattern. Have a look at this redesign:
type User = {
id: number;
name: string;
};
interface IEmployee {
getUser: () => User;
}
class Employee implements IEmployee {
getUser(): User {
return { id: 1, name: "test" };
}
}
class NullEmployee implements IEmployee {
getUser(): User {
return { id: -1, name: "Not exist user" };
}
}
// Factory function to guarantee that the IEmployee type would be returned all the time even if it was null
function employeeFactroy(employee: IEmployee) {
if (employee === null) return new NullEmployee();
return employee;
}
const employees: IEmployee[] = [new Employee(), null, null, new Employee()];
for (const employee of employees) {
const emp = employeeFactroy(employee);
const user = emp.getUser();
console.log(user);
}
Throwing Not Implemented Exception:
This is a common one because of the partial implementation of an interface or a base class. Take a look again at the example of the “LSP extends OCP” section, you have to throw a Not Implemented Exception in the method login
of the Guest
subtype because it can’t fully implement the IEmployee
interface (supertype).
The solution for this problem is to make sure to fully implement the supertype whether it was an interface or a base class.
However, you might argue that it is sometimes difficult to fully implement the interface, like our example. That’s true, if you have such a case, you probably need to double-check the relation between the supertype and subtype, the subtype might not be qualified to be a subtype for this supertype, in other words, this is probably a violation for the Interface Segregation Principle.
Conclusion
In this article, we have just introduced the Liskov Substitution Principle. We knew that LSP adds a new constraint to the object-oriented design, it states that relationships are insufficient and you need to make sure that subtypes are substitutable for supertypes.
We also knew the rules that you need to follow to apply this principle correctly. And these rules could be categorized under Signature and Behavior rules.
After that, we introduced some common violations of this principle and solutions for them.
Before you leave
Thanks a lot for staying with me up till this point, I hope you enjoy reading this article.
If you found this article useful, check out these articles as well:
Do you really know, what the Single Responsibility Principle is?
What is the difference between Strategy, State, and Template design patterns?
Top comments (0)