Overview
This piece is the third installment in a series dedicated to the SOLID acronym — a collection of guidelines that make our codebase more scalable and easier to modify without having to touch large portions of the application.
The five principles are:
- Single Responsibility,
- Open/Closed,
- Liskov Substitution,
- Interface Segregation,
- Dependency Inversion.
We now turn our attention to the Liskov Substitution Principle.
Understanding Liskov Substitution
Among the SOLID rules, Liskov is arguably the most challenging to grasp. Its formal statement goes like this:
If S is a subtype of T, then objects of type S may replace objects of type T without altering the correctness of the program. To make this more tangible, consider the following illustration:

Credit: https://devexperto.com/principio-de-sustitucion-de-liskov/principio-sustitucion-liskov-meme/
In essence, if something appears to be a duck, acts like a duck, but requires batteries to operate, then the abstraction is flawed and the type hierarchy is likely modeled incorrectly. Consider this pseudocode:
export class Duck {
quack(): void {
console.log(‘kwa’);
}
}
We have a type representing a duck that can produce quacking sounds.
Next, let's define a subtype — a battery-powered electric duck:
export class ElectricDuck extends Duck {
constructor(battery: Battery = null) {
this.battery = battery;
}
quack(): void {
if (!this.battery) {
throw new Error(‘Need battery to duck’);
}
console.log(electric kwa’);
}
}
Throughout the codebase, we used the standard duck type everywhere, but in one spot we swapped in the electric duck. Following our habit with ordinary ducks, we neglected to supply batteries. The result: the code fails (an exception is thrown), and the duck remains silent. This is a clear violation of Liskov.
What might a proper type relationship look like? Consider creating a male duck and a female duck. Both make sounds regardless of gender. Instances of a derived class should augment, rather than override, the behavior of the base class.
Adhering to this rule feels tricky. Fortunately, the principle’s authors laid out specific conditions to keep it intact.
Output Type Covariance
The first requirement is covariance for output types.
Covariance means converting from a broader type to a narrower one — for example, from Car to Rolls-Royce.
Let's examine an example:
type Mapper = (value: string | number) => string;
// covariance broken, return type widden
const myMapperBreaksCovariance: Mapper = (value: string | number): string | number => {
return parseInt(value.toString()) ? parseInt(value.toString()) : value;
}
// covariance ok, return type narrowed
const myMapperCovarianceOk: Mapper = (value: string | number): 'someString' => {
return 'someString';
}
In the initial case, the Mapper method violates output covariance because it broadens the returned types (producing both string and number).
In the second case, everything works correctly because the method tightens the return type (yielding someString instead of the generic string).
Input Type Contravariance
The next condition is contravariance for input types.
Contravariance is the inverse of covariance — moving from a more specific type to a more general one, such as from Rolls-Royce to Car.
Here's another example:
type Mapper = (value: string | number) => string;
// contravariance broken, argument type narrowed
const myMapperBreaksContravariance: Mapper = (value: string): string => {
return value;
}
// contravariance ok, argument type widden
const myMapperContravarianceOk: Mapper = (value: string | number | Array<string>): string => {
return value.toString();
}
In the first scenario, the Mapper method breaks input contravariance by restricting the accepted parameter types (only string is allowed).
The second scenario is correct because the method expands its input types (now also accepting an array of string values).
Exception Handling
Exceptions form another condition. If the subtype S (Derived) introduces new exceptions, they must be subtypes of what the general type T (Base) throws.
Look at this code:
class ArgumentException {}
class NullReferenceException {}
class Base {
call(): void {
// ...
throw new ArgumentException();
}
}
class Derived extends Base {
call(): void {
// ...
throw new NullReferenceException();
}
}
The issue here is that the Derived subtype throws an exception that is not a subtype of the base type's exception.
To resolve this, the typing relationship should extend to the exceptions thrown by the subtype.
Precondition Contracts
Next, we have contracts on prerequisites.
The rule is: a subtype cannot be more restrictive than the base type; it must handle at least the same range of data.
Imagine a library system with these services for calculating book borrowing fees:
class Base {
calculateFeeForBooks(books: Book[]): number {
return books.length * FEE;
}
}
class Derived extends Base {
calculateFeeForBooks(books: Book[]): number {
if (books.length > 3) {
return books.length * FEE;
}
}
}
The first service computes fees for regular users — after borrowing one book, they pay a rental fee. The second service (Derived) targets VIP users who pay a recurring subscription, so they only incur a rental fee from the 4th book onward.
Evidently, the VIP service cannot handle cases where the number of borrowed books is below 4. This violates the precondition requirements. We can correct this by simply returning 0 for fewer books (which aligns with business logic — the fee should indeed be 0 in that case):
class Derived extends Base {
calculateFeeForBooks(books: Book[]): number {
if (books.length > 3) {
return books.length * FEE;
}
return 0;
}
}
Postcondition Contracts
The next condition concerns contracts on final states.
The rule is: the subtype must return data that respects the constraints imposed by the base type's return values.
Check out this code:
class Base {
calculateFeeForBooks(books: Book[]): number {
const result = ...;
return Math.max(result, 10);
}
}
class Derived extends Base {
calculateFeeForBooks(books: Book[]): number {
const result = ...;
return Math.max(result, 0);
}
}
Let's stay with the library scenario. These two methods refund the rental fee. The base method always returns at least 10. In contrast, the Derived method (for VIP users) can return as low as 0. Here, we breach the postcondition — the Derived subtype violates the base type's constraint (fee amount >= 10). Fixing this might require revisiting business logic (sometimes a conscious Liskov break is acceptable).
The Invariant Rule
Another condition involves preserving invariants. What does that mean?
Let's start with examples:
- a user always has both a first and last name,
- a rectangle always has side A and side B.
An invariant is something unchangeable — the core essence of a given type.
A more formal definition: an invariant is a function mapping class states to {true, false}.
In short, it determines whether an object’s state is valid or not.
To uphold the invariant, we must ensure the object’s condition remains legal.
The History Rule
Another condition is adhering to the history constraint.
As before, let's begin with an example.
The invariant might be: the structure's size is always less than its maximum size.
Here, the history rule dictates that the structure's maximum size never changes.
A more formal definition: the history rule is a function mapping pairs of states to {true, false}.
It determines whether a transition from state A to state B is permissible.
To satisfy the history rule, object state transitions must be legal.
Wrapping Up
Liskov Substitution stands as the most intricate principle in the SOLID set. To stay compliant, one guiding thought matters most: objects of a derived type should complement, not replace, the behavior of the base type. The remaining conditions serve as checks to verify that the principle holds in your code.
The next article will tackle the Interface Segregation Principle — stay tuned.
