Designing and honoring contracts
Substitutability is behaviour, not shape: a type-correct implementation can still break callers. State contracts as pre/post/invariants, encode shape in types, and pin behaviour with one shared contract test run against every implementation so drift is caught.
Liskov’s rule is easy to recite — subtypes must be substitutable for their base — and easy to violate without anyone noticing. You ship an interface, three teams implement it, and every implementation passes its own tests. Six months later a caller swaps one implementation for another and a queue backs up, because that implementation quietly decided “acknowledge” means “schedule for later” instead of “durably stored.” Nothing threw. No test failed. The substitution was unsafe all along; you just hadn’t exercised it yet.
The reason is that the interface only pinned the shapes — method names and types. The thing that actually has to hold across implementations is the behaviour: what callers may assume before calling, what they’re promised after, and what stays true throughout. That behaviour is the contract, and an interface that doesn’t state it lets every implementor invent their own.
After this lesson you can write an explicit behavioural contract for an interface — its preconditions, postconditions, and invariants — and decide which clauses to encode in the type system versus which to pin with a shared contract test run against every implementation. You can spot a subtly conforming-looking implementation that violates a postcondition, explain why “tell, don’t ask” and narrow interfaces make contracts smaller and safer, and judge when substitutability is uncertain enough that you should reach for composition instead of a shared supertype.
A contract is three clauses: preconditions, postconditions, invariants — and substitutability is defined in terms of them. A precondition is what a caller must guarantee before calling (inputs in range, system in a valid state). A postcondition is what the implementation guarantees on return. An invariant is what stays true before and after every operation. The Liskov rule, stated precisely, is about these: a substitute may weaken preconditions (demand no more of callers) and strengthen postconditions (promise no less), and must preserve invariants — but never the reverse.
/**
* Stores a record and returns its assigned id.
* @pre record.id is unset; record passes validate()
* @post the record is durably persisted before this resolves;
* get(returnedId) returns an equal record
* @inv ids are never reused, even after delete
*/
interface Repository<T extends { id?: string }> {
save(record: T): Promise<string>;
get(id: string): Promise<T | null>;
}The doc comment is not decoration — it is the part of the interface a caller actually relies on. A save that returns before the write is durable hasn’t changed the type, but it has broken the contract.
A conforming implementation and a subtly violating one can be type-identical — types catch shape, not behaviour. Here are two repositories. Both satisfy the TypeScript interface exactly. One honours the contract; the other strengthens a precondition and weakens a postcondition, which is precisely the illegal direction.
// Conforming: weaker preconditions are fine, postcondition is met.
class PgRepository<T extends { id?: string }> implements Repository<T> {
async save(record: T): Promise<string> {
const id = crypto.randomUUID();
await this.db.insert({ ...record, id }); // awaits durability
return id; // post: persisted before resolve
}
async get(id: string) { return this.db.findById(id); }
}
// Violating — yet it compiles and passes naive unit tests.
class CacheRepository<T extends { id?: string }> implements Repository<T> {
async save(record: T): Promise<string> {
if (!record.id) throw new Error("id required"); // STRENGTHENED precondition
this.buffer.push(record); // not yet durable
return record.id; // WEAKENED postcondition
}
async get(id: string) { return this.map.get(id) ?? null; }
}CacheRepository demands callers supply an id (the base said they must not), and returns before the record is durable (the base promised it would be). A caller written against the contract — “I can save without an id and trust it’s saved” — breaks the moment it’s handed this implementation. The compiler is silent, because nothing about the types is wrong.
Encode what you can in types; pin the rest with a shared contract test run against every implementation. Some clauses move into the type system: make id genuinely optional on input and required on output (save(r: Omit<T,"id">): Promise<T & {id: string}>) and a caller can’t be forced to pass one. But durability, ordering, id-non-reuse — properties of behaviour over time — can’t be expressed in a structural type. For those, write one test suite parameterised over a factory, and run it against each implementation. The suite is the executable contract; an implementation that drifts fails the shared test, not just its own.
// contract.test.ts — one suite, every implementation must pass it.
export function repositoryContract(make: () => Repository<Doc>) {
test("save assigns an id and persists before resolving", async () => {
const repo = make();
const id = await repo.save({ title: "x" }); // no id supplied
expect(await repo.get(id)).toMatchObject({ title: "x" }); // durable now
});
test("ids are never reused after delete", async () => { /* ... */ });
}
// Each implementation opts in — drift in any one is caught here, not in prod.
describe("PgRepository", () => repositoryContract(() => new PgRepository()));
describe("CacheRepository",() => repositoryContract(() => new CacheRepository()));CacheRepository now fails the first assertion (it required an id). The contract stopped being a comment people skim and became a gate every implementation must clear.
Shrink the contract: “tell, don’t ask” and narrow interfaces leave fewer clauses to honour — and fewer to violate. Every method and every getter you expose is a clause implementors must get right and a thing callers can couple to. “Tell, don’t ask” — give the object a command and let it decide, instead of pulling out its state and deciding for the caller — removes whole families of preconditions (“only call applyDiscount when isEligible is true” becomes “call checkout(), it knows”). Interface segregation does the same structurally: a fat Repository with save/get/query/stream/migrate forces every implementation to honour clauses it doesn’t need. Split it, and an in-memory cache implements Reader only — there’s no migrate postcondition for it to silently get wrong.
// Narrow contracts: each implementation honours only the clauses it actually has.
interface Reader<T> { get(id: string): Promise<T | null>; }
interface Writer<T> { save(record: Omit<T, "id">): Promise<T & { id: string }>; }
// A read-through cache is a Reader. It can't violate a Writer postcondition
// it was never asked to make.A smaller contract is not just less code — it is a smaller substitutability surface, so there are fewer ways for an implementation to be subtly wrong.
When substitutability is genuinely uncertain, prefer composition over inheritance. A shared supertype is a promise that every implementation is interchangeable. If you can’t actually make that promise — the classic Square extends Rectangle where setWidth must break Rectangle’s independent-sides invariant, or a ReadOnlyList extends List whose add must throw — then forcing the inheritance ships a lie that callers will trust and that no shared test can rescue, because the contract itself is unsatisfiable. The senior move is to stop forcing the is-a and use has-a: the read-only collection wraps a list and exposes only the operations it can truly honour. No false supertype, no Liskov violation, no throw new Error("unsupported") lurking behind a method the type says exists.
// Don't: ReadOnlyList "is a" List but can't honour add()/remove().
// Do: it "has a" list and exposes only what it can truly promise.
class ReadOnlyView<T> {
constructor(private readonly items: readonly T[]) {}
get(i: number) { return this.items[i]; }
get length() { return this.items.length; }
// no add/remove to lie about
}A payment gateway interface that drifts in production. A team defines one interface and lets each provider implement it:
// Before: contract lives in nobody's head.
interface PaymentGateway {
charge(amountCents: number, token: string): Promise<{ id: string }>;
}Stripe’s adapter charges synchronously and returns once the charge is captured. The new PayPal adapter returns as soon as the request is accepted — capture happens later via webhook. Both compile. Both pass their own integration tests. A caller does const { id } = await gateway.charge(...); markOrderPaid(id); — correct for Stripe, wrong for PayPal, where the order is marked paid before the money moves. The bug only appears after the swap, in production, intermittently.
// After: the contract is explicit AND pinned by a shared test.
interface PaymentGateway {
/**
* @pre amountCents > 0; token is a single-use payment token
* @post resolves ONLY after funds are captured; never reuses an id
*/
charge(amountCents: number, token: string): Promise<{ id: string; captured: true }>;
}
// gateway-contract.test.ts — run against Stripe AND PayPal adapters.
export function gatewayContract(make: () => PaymentGateway) {
test("resolves only after capture", async () => {
const g = make();
const res = await g.charge(100, testToken());
expect(res.captured).toBe(true); // PayPal's accept-only adapter FAILS here
expect(await isCaptured(res.id)).toBe(true);
});
}The PayPal adapter now fails the shared test at integration time, with a clear message, instead of silently mis-marking orders in production. The fix is also now obvious: the adapter must await the capture webhook before resolving, or the interface must split authorize from capture so the postcondition it can actually meet is the one it advertises. Either way the drift was caught — by a single test that no implementation gets to skip.
▸Why this works
Why a shared test and not just thorough per-implementation tests? Because per-implementation tests encode each author’s interpretation of the contract — and the whole failure mode is that interpretations diverge. If PayPal’s author believed “charge” meant “accept,” their own tests will faithfully assert the wrong thing and pass. A shared suite, written once against the interface, is the single place the contract is stated executably; every implementation is measured against the same assertions, so a drifting interpretation can’t hide behind its own green tests. It’s the difference between everyone grading their own exam and one exam graded the same way for all.
▸Common mistake
The seductive mistake is “it implements the interface, so it’s substitutable.” Implementing the interface only proves the shapes line up; the compiler never read your @post comment. Substitutability is a property of behaviour, and behaviour is invisible to a structural type. So the presence of implements PaymentGateway tells you nothing about whether charge actually captures before resolving. Treat every interface as having two halves — the typed shape and the behavioural contract — and remember the compiler only checks the first. The second is yours to pin, in types where you can and in a shared test where you can’t.
You ship an interface, three teams implement it, and each implementation passes its own unit tests. Months later, swapping one implementation for another causes an intermittent production bug. What is the most reliable way to have caught this before the swap?
Substitutability is a property of behaviour, not shape, so a type-correct implementation can still be unsafe to swap in. State the contract explicitly as three clauses — preconditions (a substitute may only weaken them), postconditions (may only strengthen them), and invariants (must preserve them) — then pin each clause where it actually lives: encode shape in the type system where you can, and pin behaviour-over-time with one shared contract test run against every implementation where you can’t. Shrink the contract with tell-don’t-ask and narrow, segregated interfaces so there are fewer clauses to honour and fewer ways to drift. And when no honest supertype can satisfy the contract, prefer composition over inheritance rather than shipping an unsatisfiable promise. The failure mode this defends against is silent: each implementation reinterprets the contract its own way, every per-implementation test passes, and substitutability erodes invisibly until a swap in production exposes it. One shared test is what makes the drift loud instead of silent.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.