Skip to main content

The Liskov substitution principle — the types pass, the contract does not hold

LSP is the one principle of the five where it looks as though you could decide mechanically whether you broke it, and in fact you cannot. An inheritance relationship can hold as a matter of types and still break when you substitute.

This chapter extracts from the original text the criterion for deciding substitutability. It then confirms how far TypeScript's type checking looks against that criterion.

What the original said — two formulations

LSP has two formulations with different origins.

The Liskov and Wing formulation

The starting point on the theory side is Barbara Liskov and Jeannette Wing's 1994 paper1. They state the requirement for a subtype this way.

Subtype Requirement: 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 a stronger claim than saying "you can substitute." It says whatever is provable about the base type must also hold for the derived type.

The paper divides the properties to be preserved into two kinds.

We focus on two kinds of such properties: invariants, which are properties true of all states, and history properties, which are properties true of all sequences of states.

And it gives an example.

an invariant property of a bag is that its size is always less than its bound; a history property is that the bag's bound does not change.

The difference between the two matters. An invariant is a property that is true at whatever moment you slice, and you can decide it by looking at one state. A history property is true across time, and you cannot decide it without watching how the state moves.

The Martin formulation

What gets quoted on the practitioner side is the wording of Martin's 2000 document2.

Subclasses should be substitutable for their base classes.

Martin attributes the principle to Liskov and then writes that it can be derived from Meyer's design by contract. He restates it in the language of contracts.

a derived class is substitutable for its base class if:

  1. Its preconditions are no stronger than the base class method.
  2. Its postconditions are no weaker than the base class method.

Or, in other words, derived methods should expect no more and provide no less.

"Expect no more and provide no less" — that is the criterion you can use in practice.

The checklist for substitutability

There are four items to confirm. Liskov and Wing's paper includes all four in its definition; what Martin pulled out for practitioners is the first two.

ItemWhat the derived type may doWhat happens when you break it
PreconditionMay weaken it. Must not strengthen itInput the base type accepted is rejected by the derived type
PostconditionMay strengthen it. Must not weaken itThe caller does not get a result it was supposed to be guaranteed
InvariantPreserve it. Must not relax itThe consistency of state the base type assumed falls apart
History propertyPreserve itThe state at each point is correct, yet it changes in a way the base type forbade

Note that the directions are opposite. On the input side (precondition) loosening is safe; on the output side (postcondition) tightening is safe. From the caller's point of view, both mean "whatever the base type could do, the derived type can do too."

Beyond these four, the paper's definition also imposes rules about the shape of the types. Argument types are contravariant (they may be wider than the base type's argument types), result types are covariant (they may be narrower than the base type's result type), and for exceptions there is this rule.

Exception rule. The exceptions signaled by m_σ are contained in the set of exceptions signaled by m_τ.

The rule is that the derived type must not throw an exception the base type does not throw. This comes into play in the next section.

Why follow it — the premise that makes dependence on abstraction work

LSP is not a principle with value on its own. It is the premise that makes the previous chapter's OCP work.

OCP claimed that depending on an abstraction lets you extend without modifying. But if an implementation does not behave as expected, whoever depends on the abstraction ends up special-casing that implementation. Martin writes in the original:

Thus, violations of LSP are latent violations of OCP.

When substitution breaks, a branch appears at the call site saying "only for this implementation, do something else." That branch is exactly what OCP was trying to eliminate.

How it gets misused — weakening the postcondition

The most common violation is the shape in which the derived type ends up guaranteeing less than the base type.

Let us look at library loan records. The base type can extend a loan period.

❌ Bad: the derived type weakens the postcondition
class LendingRecord {
constructor(
readonly itemId: string,
protected dueOn: Date,
) {}

/** Extends the due date by days. After the call, dueOn is always days later */
extend(days: number): void {
this.dueOn = new Date(this.dueOn.getTime() + days * 86_400_000);
}

get dueDate(): Date {
return this.dueOn;
}
}

class ReservedLendingRecord extends LendingRecord {
/** Items with a hold on them are not extended */
extend(days: number): void {
// Does nothing
}
}

The postcondition of extend was "after the call, the due date has moved out." ReservedLendingRecord does not guarantee that. And it fails to guarantee it silently. It throws no exception and has no return value, so the caller assumes the extension happened.

❌ Bad: the caller believes the extension happened
function extendAllLoans(records: readonly LendingRecord[], days: number): void {
for (const record of records) {
record.extend(days);
// Sending a notice here on the assumption that record.dueDate has moved out
// makes the notice a lie for ReservedLendingRecord
}
}

Throwing an exception does not fix it either. If the base type's extend throws nothing, having the derived type throw violates the exception rule from the previous section, because the exceptions a derived type may signal have to be contained in the set the base type signals. Put in terms of the caller, a method that could always be called on the base type has become conditional on the derived type.

The fix is to redesign the contract itself.

✅ Good: express the inability to extend through the type and the return value
type ExtensionResult =
| { readonly extended: true; readonly newDueDate: Date }
| { readonly extended: false; readonly reason: string };

/**
* A loan record.
* History property: dueOn is never moved earlier (no operation moves it into the past)
*/
class LendingRecord {
constructor(
readonly itemId: string,
protected dueOn: Date,
) {}

/** Attempts an extension. Success or failure is shown in the return value, which the caller always receives */
tryExtend(days: number): ExtensionResult {
this.dueOn = new Date(this.dueOn.getTime() + days * 86_400_000);
return { extended: true, newDueDate: this.dueOn };
}

get dueDate(): Date {
return this.dueOn;
}
}

class ReservedLendingRecord extends LendingRecord {
tryExtend(days: number): ExtensionResult {
return { extended: false, reason: "There is a hold on this item" };
}
}

The postcondition changed from "it is extended" to "it returns whether the extension is possible." The derived type satisfies that. The contract was redesigned around the weaker case; the derived type was not forced to fit the base type.

Violations of a history property are hard to find

Preconditions and postconditions can be confirmed per method; history properties cannot. The base LendingRecord wrote the property "dueOn is never moved earlier" into its contract. Because it is a promise about a sequence of states, no single method shows you whether it holds.

❌ Bad: the state at each point is correct, but the way it changes differs
class RecallableLendingRecord extends LendingRecord {
/** Recalls an item that has a hold on it. Moves the due date up to today */
recall(): void {
this.dueOn = new Date();
}
}

The inherited tryExtend is untouched, so the precondition and postcondition are unchanged. Slice the state at any point and it looks like a valid loan record with an itemId and a dueOn. What it breaks is the history property. The base type promised not to move dueOn earlier, and recall moves the state in the direction that promise forbade.

The violation is visible only when you line up the states before and after recall and compare. Because a snapshot at any single point will not show it, tests miss it easily. Liskov and Wing separated invariants from history properties in order to handle this difference.

What the standard examples are showing

The example most often used to explain LSP is the rectangle and the square: a square is a kind of rectangle, yet inheriting breaks. This site's SOLID Principles explains it in that form too.

What Martin's original uses is the circle and the ellipse. The structure is the same: a circle is a degenerate ellipse, yet give it a method for setting the foci and substitution breaks. He explains why in terms of postconditions. The method for setting the foci guaranteed that "the two points you pass are held as given," and a circle cannot guarantee that; that is the substance of the violation.

The lesson to draw from this kind of example is not "a square is not a rectangle." The mathematical containment is correct. What breaks is the moment you give the type an operation that changes width and height separately.

In other words, this is not a story about geometry but a story about how hard it is to inherit mutable objects. Design them as read-only types and both the square and the circle become subtypes without trouble. Withhold the operation that breaks it and nothing breaks.

Memorize the standard example as it stands and all you retain is the vague lesson that "inheritance has pitfalls." Go all the way down to which operation broke the contract and you become able to decide in your own code.

When it's OK to break it

LSP differs in character from the other four principles. There is almost no situation in which breaking it is acceptable.

The reason is that an LSP violation is a lie told by the types. Break SRP and code that runs still runs. Break OCP and every addition takes more work, but it works correctly. Break LSP and what the caller believed to be true stops holding.

The realistic option is not "break it" but "stop inheriting."

  • If the contract does not fit, do not inherit. Switch to delegation and the demand for substitutability never arises
  • Redesign the contract around the weaker case. tryExtend above is that shape
  • Carve out only the common part as a separate type. With read-only operations alone, substitution often does hold

Note, though, that LSP is not a principle the language enforces for you. As the next section shows, passing the type checker does not mean the contract is being kept.

Where TypeScript stands today — the type checker is not looking at the contract

Liskov and Wing argue early in the paper that the rules of a type system alone are not enough.

these rules are not strong enough to ensure that the program containing the above assignment will work correctly for any subtype of T, since all they do is ensure that no type errors will occur.

The example they give is apt.

stacks and queues might both have a put method to add an element and a get method to remove one. According to the contravariance rule, either could be a legal subtype of the other. However, a program written in the expectation that x is a stack is unlikely to work correctly if x actually denotes a queue, and vice versa.

TypeScript's structural type system makes this situation more likely. Assignment succeeds when the shape matches rather than the name, so types with different meanings become mutually assignable.

❌ Bad: same shape means it is assignable
type LendingQueue = {
add(itemId: string): void;
take(): string | undefined; // Takes out whatever went in first
};

type LendingStack = {
add(itemId: string): void;
take(): string | undefined; // Takes out whatever went in last
};

declare const stack: LendingStack;
const queue: LendingQueue = stack; // Not a type error

The types pass. The contract about the order of removal is written nowhere in the types.

The check changes with method syntax versus property syntax

There is one more TypeScript-specific pitfall. How strictly argument types are checked depends on the syntax you declare the method with.

The contravariance check on arguments does not apply with method shorthand
type Borrower = { id: string };
type StudentBorrower = Borrower & { schoolId: string };

// Declared with method shorthand
type MethodStyle = {
notify(borrower: StudentBorrower): void;
};
declare const methodStyle: MethodStyle;
// A function that only accepts the narrow argument is assignable to a type that promised to accept the wide one
const widened: { notify(borrower: Borrower): void } = methodStyle;

// Declared as a property with a function type
type PropertyStyle = {
notify: (borrower: StudentBorrower) => void;
};
declare const propertyStyle: PropertyStyle;
// @ts-expect-error strictFunctionTypes checks the argument contravariantly, so this is not assignable
const widenedStrict: { notify: (borrower: Borrower) => void } = propertyStyle;

Two spellings that look identical in meaning give different results from the check. Method shorthand is treated as bivariant (assignment in either direction is allowed) for historical compatibility and is outside the scope of strictFunctionTypes. Written as a property with a function type, it is checked contravariantly.

Being contravariant is a property the Liskov and Wing definition requires. The paper's rules about the shape of types state the following about arguments.

Contravariance of arguments. m_τ and m_σ have the same number of arguments. If the list of argument types of m_τ is α_i and that of m_σ is β_i, then ∀i . α_i < β_i.

That is, a derived type's method has to accept wider argument types than the base type's. Method shorthand skips this check, so something that is not a subtype under the paper's definition becomes assignable in TypeScript.

If you want the types to enforce the contract, declaring it as a property with a function type gets you the stricter check. But all that protects is the argument types. The order of removal, and the history property seen above, remain outside the types.

The type checker sees only part of the contract. The rest can only be protected by design and tests — that is this section's conclusion.

Summary

  • Liskov and Wing's requirement is that "properties provable about the base type also hold for the derived type." The paper divides the properties to be preserved into invariants and history properties
  • Martin condensed it into two conditions in the language of contracts: expect no more and provide no less
  • There are four items to confirm — a precondition may be weakened, a postcondition may be strengthened, and an invariant and a history property are preserved
  • A history property violation cannot be seen from the state at any single point. Tests miss it easily
  • An LSP violation is a latent OCP violation. When substitution breaks, a branch appears at the call site
  • LSP has almost no "acceptable to break" situation. What to choose is to stop inheriting, or to redesign the contract
  • The type checker sees only part of the contract. In a structural type system, types with different meanings become mutually assignable

The next chapter covers the interface segregation principle. The property seen in this chapter — that types do not state the contract — also affects how you decide to carve interfaces.

Exercises

Which of the four items does the following derived type break? If it breaks none, explain why it still feels wrong
class HoldQueue {
protected entries: string[] = [];

/** Appends a hold at the end. After the call, the count always increases by one */
add(borrowerId: string): void {
this.entries.push(borrowerId);
}

/** Takes the hold at the front. undefined if empty */
takeNext(): string | undefined {
return this.entries.shift();
}

get size(): number {
return this.entries.length;
}
}

class PriorityHoldQueue extends HoldQueue {
add(borrowerId: string): void {
if (borrowerId.startsWith("staff-")) {
this.entries.unshift(borrowerId);
} else {
this.entries.push(borrowerId);
}
}
}

Sample answer

The answer changes with how you read the base type's contract. Read the doc comment in the problem as the contract and it is a postcondition violation.

Looking only at the promise about the count, it breaks none of the four items.

  • Precondition: add can be called at any time. Same as the base type, not strengthened
  • Postcondition: "after the call, the count always increases by one" is satisfied. Whether by unshift or push, the count goes up by one. What we are looking at here is only the second half of the doc comment (the count); the first half, "at the end," comes below
  • Invariant: state properties such as "the count is zero or greater" are preserved
  • History property: the way the count rises and falls is the same as the base type. Add and it goes up, take and it goes down

The reason it still feels wrong is that "appends at the end," written in the base type's doc comment, is not being treated as part of the contract. The description of add was "appends a hold at the end. After the call, the count always increases by one." The first half is a promise about position, the second about the count. The derived type keeps only the second.

This is where the decision splits.

Include "appends at the end" in the contract and it is a postcondition violation. Since takeNext takes from the front, the insertion position determines the order of removal. A caller written on the assumption of that order (say, "notify in the order the holds were placed") breaks. This is exactly the situation Liskov and Wing pointed out with the stack and queue example.

Leave it out and it is not a violation. In that case, however, you should delete "at the end" from the base type's description. Write something into a comment that is not part of the contract and readers will take it for the contract and depend on it.

The practical answer is "make the base type's contract explicit," not to fix the derived type. If order carries meaning, write it into the contract, and having written it, do not change it in a derived type. If order carries no meaning, remove the mention of order from the base type's description.


Footnotes

  1. Source: Barbara H. Liskov, Jeannette M. Wing, "A Behavioral Notion of Subtyping", ACM Transactions on Programming Languages and Systems, Vol. 16, No. 6, November 1994, pp. 1811-1841. The quotations in this chapter come from Section 1 (the introduction) and from Fig. 4 in Section 5, which gives the definition of the subtype relation. The paper defines the subtype relation in the notation of formal specification. This chapter quotes only the two rules from Fig. 4 that can be read without following that notation (contravariance of arguments and the exception rule) and does not enter into the proof system.

  2. Source: Robert C. Martin, "Design Principles and Design Patterns" (2000), the LSP section on pages 8-12. For the PDF referred to, see the footnotes in chapter 1. The circle and ellipse example, and the restatement in terms of contracts, are in that section.