---
title: 'Liskov Substitution Principle'
source: 'https://academia.sh/en/courses/design-principles/liskov-substitution-principle'
course: 'Design Principles'
language: en
updated: '2026-08-23T07:01:17+00:00'
license: 'CC BY-SA 4.0'
---

# Liskov Substitution Principle

Defining the subtype contract through preconditions and postconditions: running a contract test that operates through the supertype against every tariff in the registry, two tariffs that strengthen the precondition and weaken the postcondition breaking the test, and satisfying the same requirement without breaking the contract.

The open–closed principle moved the tariff type into a registry; the fee module worked
without asking which tariff it received. This rests on the assumption that every object
inside the registry genuinely satisfies the same contract. The assumption was not
written down: the registry only saw that the names `base` and `description` existed,
not what these functions accepted or returned.

The **Liskov substitution principle** turns that assumption into a rule: wherever a
supertype is expected, one of its subtypes must be usable in its place without breaking
the program's correctness. The **subtype** relationship defined in the TypeScript course
is a structural compatibility the type checker sees; this principle wants the same
relationship at the level of behavior. The rule comes down to two clauses: a subtype
cannot **demand less than** what the supertype accepts (it cannot strengthen the
precondition), and it cannot **give less than** what the supertype promises (it cannot
weaken the postcondition).

## Writing the Contract

The contract must stand beside the supertype, not as a comment but in a testable form.
The first step is defining the supertype and writing the expectation explicitly.

```js
// tariff.mjs — supertype and the contract that must be satisfied
export class Tariff {
  constructor(name) {
    this.name = name;
  }

  // Precondition: shipment carries weight, width, length, height, and address fields.
  // Postcondition: returns a positive integer (cents), the same value for the same shipment.
  base(shipment) {
    throw new Error(`${this.name}: base not implemented (${typeof shipment})`);
  }

  // Postcondition: returns a non-empty string.
  description(shipment) {
    throw new Error(`${this.name}: description not implemented (${typeof shipment})`);
  }
}
```

```js
// tariffs.mjs — three subtypes that satisfy the contract
import { Tariff } from "./tariff.mjs";

export class WeightTariff extends Tariff {
  constructor() { super("weight"); }
  base(s) { return s.weight <= 1 ? 3900 : s.weight <= 5 ? 6400 : 11800; }
  description(s) { return `weight tier (${s.weight} kg)`; }
}

export class FixedTariff extends Tariff {
  constructor() { super("fixed"); }
  base() { return 4500; }
  description() { return "fixed fee"; }
}

export class VolumeTariff extends Tariff {
  constructor() { super("volume"); }
  volumetricWeight(s) { return Math.ceil((s.width * s.length * s.height) / 5000); }
  base(s) { return 2600 + this.volumetricWeight(s) * 700; }
  description(s) { return `volume tariff (${this.volumetricWeight(s)} volumetric kg)`; }
}
```

The registry does the same job as in the previous lesson; the only difference is that it
holds instances instead of objects.

```js
// registry.mjs — tariff registry: access by name and the list of registered tariffs
const TABLE = new Map();

export function register(t) {
  TABLE.set(t.name, t);
}

export function tariff(name) {
  const t = TABLE.get(name);
  if (t === undefined) throw new RangeError(`unknown tariff: ${name}`);
  return t;
}

export const all = () => [...TABLE.values()];
```

```js
// setup.mjs — registers the three tariffs that satisfy the contract
import { register } from "./registry.mjs";
import { WeightTariff, FixedTariff, VolumeTariff } from "./tariffs.mjs";

register(new WeightTariff());
register(new FixedTariff());
register(new VolumeTariff());
```

```js
// fee.mjs — calculation: does not know the tariff's type, only trusts the contract
import { tariff } from "./registry.mjs";

const ZONE = { "34": 100, "06": 115, "65": 140 };
const zoneFactor = (address) => ZONE[address.slice(0, 2)] ?? 160;

export const fee = (s) => Math.round((tariff(s.tariff).base(s) * zoneFactor(s.address)) / 100);
export const description = (s) => tariff(s.tariff).description(s);
```

```js
// main.mjs — composition root: loads the setup, writes the fee lines
import "./setup.mjs";
import { fee, description } from "./fee.mjs";

const SAMPLE = [
  { tariff: "weight", weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { tariff: "fixed", weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
  { tariff: "volume", weight: 12.0, width: 60, length: 40, height: 40, address: "65200" },
];

for (const s of SAMPLE) console.log(`${fee(s)} cents  ${description(s)}`);
```

```sh
node main.mjs
```

```
3900 cents  weight tier (0.8 kg)
5175 cents  fixed fee
23240 cents  volume tariff (20 volumetric kg)
```

## A Test That Operates Through the Supertype

The contract test does not know the subtypes' names. It takes each tariff in the
registry in turn and checks the supertype's two clauses. Its measure is a single number:
the count of broken tests.

```js
// contract-test.mjs — operates through the supertype, does not know the subtype names
import test from "node:test";
import assert from "node:assert/strict";
import { Tariff } from "./tariff.mjs";
import { all } from "./registry.mjs";

// Shipments the supertype accepts. No subtype may reject these.
const SHIPMENTS = [
  { weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
  { weight: 12.0, width: 60, length: 40, height: 40, address: "65200" },
];

const attempt = (f) => {
  try { return { value: f(), error: null }; } catch (e) { return { value: null, error: e.message }; }
};

await import(process.env.SETUP ?? "./setup.mjs");

for (const t of all()) {
  test(`${t.name}: subtype of the supertype`, () => {
    assert.ok(t instanceof Tariff, `${t.name} does not derive from the supertype`);
  });
  test(`${t.name}: precondition not strengthened`, () => {
    for (const s of SHIPMENTS) {
      const r = attempt(() => t.base(s));
      assert.ok(r.error === null, `${t.name} rejected shipment ${s.address}: ${r.error}`);
    }
  });
  test(`${t.name}: postcondition not weakened`, () => {
    for (const s of SHIPMENTS) {
      const r = attempt(() => t.base(s));
      assert.ok(Number.isInteger(r.value) && r.value > 0,
        `${t.name} base for ${s.address} = ${r.error ?? r.value}`);
      assert.ok(attempt(() => t.description(s)).value?.length > 0, `${t.name} description for ${s.address} is empty`);
    }
  });
}
```

The setup module is read from an environment variable, so the same test can be run with
different registries. Because the report format includes timing and file-path lines, the
duration and location lines have been filtered out.

```sh
node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - weight: subtype of the supertype
ok 2 - weight: precondition not strengthened
ok 3 - weight: postcondition not weakened
ok 4 - fixed: subtype of the supertype
ok 5 - fixed: precondition not strengthened
ok 6 - fixed: postcondition not weakened
ok 7 - volume: subtype of the supertype
ok 8 - volume: precondition not strengthened
ok 9 - volume: postcondition not weakened
# tests 9
# pass 9
# fail 0
```

## Two Subtypes That Break the Contract

Two new requirements arrive: a separate tariff for international shipments and a free
campaign for light shipments. Both are written by deriving from the supertype, in the
first form that comes to mind.

```js
// breaking-tariffs.mjs — two subtypes that break the contract in two different places
import { Tariff } from "./tariff.mjs";

// Strengthens the precondition: the supertype accepts every shipment, this subtype requires `country`.
export class InternationalTariff extends Tariff {
  constructor() { super("international"); }
  base(s) {
    if (s.country === undefined) throw new RangeError("international: country field required");
    return 9800 + Math.ceil(s.weight) * 1200;
  }
  description(s) { return `international (${s.country})`; }
}

// Weakens the postcondition: the supertype promises a positive integer, this subtype returns 0.
export class CampaignTariff extends Tariff {
  constructor() { super("campaign"); }
  base(s) { return s.weight <= 1 ? 0 : 3900; }
  description() { return "campaign"; }
}
```

```js
// setup-broken.mjs — same three tariffs, plus two that break the contract
import "./setup.mjs";
import { register } from "./registry.mjs";
import { InternationalTariff, CampaignTariff } from "./breaking-tariffs.mjs";

register(new InternationalTariff());
register(new CampaignTariff());
```

The same test, unchanged, is run with the second setup.

```sh
SETUP=./setup-broken.mjs node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
```

```
not ok 11 - international: precondition not strengthened
  error: 'international rejected shipment 34100: international: country field required'
not ok 12 - international: postcondition not weakened
  error: 'international base for 34100 = international: country field required'
not ok 15 - campaign: postcondition not weakened
  error: 'campaign base for 34100 = 0'
# tests 15
# pass 12
# fail 3
```

The number of tests rose from nine to fifteen, and three broke. Both tariffs write
`extends Tariff` and define both methods; structural compatibility is fine. What breaks
is the behavior contract.

## What the Violation Looks Like in a Run

A broken test is not an abstract warning. The result becomes visible when the fee
module, unchanged, is run through the same registry.

```js
// main-broken.mjs — fee module unchanged, runs with the breaking tariffs
import "./setup-broken.mjs";
import { fee, description } from "./fee.mjs";

const SAMPLE = [
  { tariff: "campaign", weight: 0.8, width: 20, length: 20, height: 15, address: "34100" },
  { tariff: "international", weight: 3.0, width: 30, length: 40, height: 50, address: "06500" },
];

for (const s of SAMPLE) {
  try {
    console.log(`${s.tariff.padEnd(13)} -> ${fee(s)} cents  ${description(s)}`);
  } catch (e) {
    console.log(`${s.tariff.padEnd(13)} -> ${e.constructor.name}: ${e.message}`);
  }
}
```

```sh
node main-broken.mjs
```

```
campaign      -> 0 cents  campaign
international -> RangeError: international: country field required
```

The two results show two different forms of violation. The strengthened precondition
turned into an error: the fee module was rejected by the very data it sent. The weakened
postcondition is quieter — a zero-cent fee was produced and no error appeared anywhere.
The second form is dangerous, because only someone who looks at the invoice notices it.

## The Same Requirement Without Breaking the Contract

Both requirements can be satisfied; what breaks the contract is not the requirements
themselves but how they are written. The precondition is extended by turning the missing
field into a defined default instead of an error. The postcondition is preserved by
bounding the discount with a minimum fee.

```js
// compliant-tariffs.mjs — the same two requirements, without breaking the contract
import { Tariff } from "./tariff.mjs";

// Precondition not extended: if there is no country field, the domestic value derived from the address is used.
export class InternationalTariff extends Tariff {
  constructor() { super("international"); }
  base(s) { return 9800 + Math.ceil(s.weight) * 1200; }
  description(s) { return `international (${s.country ?? "domestic"})`; }
}

// Postcondition preserved: the discount cannot drop below the minimum fee.
export class CampaignTariff extends Tariff {
  constructor() { super("campaign"); }
  base(s) { return Math.max(1500, s.weight <= 1 ? 0 : 3900); }
  description() { return "campaign"; }
}
```

```js
// setup-compliant.mjs — three tariffs, plus two that satisfy the contract
import "./setup.mjs";
import { register } from "./registry.mjs";
import { InternationalTariff, CampaignTariff } from "./compliant-tariffs.mjs";

register(new InternationalTariff());
register(new CampaignTariff());
```

```sh
SETUP=./setup-compliant.mjs node --test --test-reporter=tap contract-test.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
```

```
# tests 15
# pass 15
# fail 0
```

All fifteen tests pass. There is also a third option, and sometimes it is the correct
one: if a requirement genuinely wants more than what the supertype accepts, that
requirement is not a subtype. If international pricing is meaningless without country
information, it should not register into the `Tariff` registry; it should be a separate
type with its own contract. What the principle says is not "everything should derive
from the same supertype" but "everything that derives should keep the supertype's word."

## The Scope of the Contract

Precondition and postcondition are not the whole contract. The invariants the supertype
preserves must also hold in the subtype: if `base` always returns the same value for the
same shipment, a subtype cannot add a random factor. This is why the test's three
clauses use the same shipment list; if a subtype gives a different result on a second
call, the third clause breaks.

What happens when the contract is not written down was also measured: the registry
accepted all five tariffs, the fee module ran both, and one silently produced the wrong
fee. Once the contract was written, the same registry gave three broken tests. The
difference is not in the code itself but in where the error becomes visible.

## Summary

- The Liskov substitution principle wants behavioral compatibility, not structural
  compatibility: wherever a supertype is expected, a subtype must be usable without
  breaking the program's correctness.
- The rule has two clauses: a subtype cannot strengthen the precondition and cannot
  weaken the postcondition.
- The contract test is written through the supertype and does not know the subtypes'
  names; it runs every type in the registry through the same criterion.
- With three compliant tariffs, all 9 tests passed; when two tariffs that strengthen the
  precondition and weaken the postcondition were added, 3 of 15 tests broke.
- The strengthened precondition turned into an error in the run, and the weakened
  postcondition silently produced a zero-cent fee; the second form is invisible without a
  contract test.

## Next Step

The contract test ran through two methods, and every tariff genuinely used both. This
convenience disappears once the tariff interface grows. When carrier selection, route
planning, and delivery status are gathered into a single carrier interface, a client
that cares only about the fee calculation also becomes bound to methods it does not use;
writing a fake carrier requires filling in all of those methods. The next lesson
measures the number of methods a client genuinely calls against the number of methods
the interface imposes, and counts the cost a bloated interface adds to the
fake-dependency apparatus.
