Keep the Promise
The Liskov Substitution Principle says: anywhere the code expects an interface, every implementation of that interface must work correctly. An implementation that technically fits but behaves badly — throws, does nothing, lies — breaks every function that trusted the interface.
An interface is a promise. fly() promises flying. A class that implements fly() by crashing has signed a contract it cannot keep.
A Substitute That Breaks the Deal
The Bird interface promises fly(). Sparrows deliver. Penguins are birds too — so it feels natural to implement the interface... by throwing an error, because penguins cannot fly.
Now airShow, which works for every honest bird, crashes on a penguin. The bug is not in airShow — it is in the substitute that broke the promise.
The penguin that crashes the show
airShow trusts the interface and calls fly() on every bird. The sparrow performs; the penguin throws and stops the show right there (the try/catch only softens the landing to show the message).
Output: TypeScript file and captured output
interface Bird {
fly(): string
}
class Sparrow implements Bird {
fly(): string {
return "sparrow: flying low"
}
}
// Penguin signs the contract — but cannot keep it.
class Penguin implements Bird {
fly(): string {
throw new Error("penguins cannot fly")
}
}
function airShow(birds: Bird[]): void {
for (const b of birds) {
console.log(b.fly())
}
}
try {
airShow([new Sparrow(), new Penguin()])
} catch (err) {
console.log("the show crashed:", (err as Error).message)
}
Separate the Promises
The fix: promise only what is true. Split the fat promise into Flyer and Swimmer. The sparrow implements Flyer, the penguin implements Swimmer — and no class signs a contract it cannot keep.
The best part is who catches the mistake now: passing a penguin to airShow is no longer a crash while the program runs — it is a type error, reported before the code ever starts.
Every type keeps its promise
airShow accepts Flyer, poolShow accepts Swimmer. Each animal appears only where its promise holds — substitution is safe by construction.
Output: TypeScript file and captured output
interface Flyer {
fly(): string
}
interface Swimmer {
swim(): string
}
class Sparrow implements Flyer {
fly(): string {
return "sparrow: flying low"
}
}
class Penguin implements Swimmer {
swim(): string {
return "penguin: diving deep"
}
}
function airShow(flyers: Flyer[]): void {
for (const f of flyers) {
console.log(f.fly())
}
}
function poolShow(swimmers: Swimmer[]): void {
for (const s of swimmers) {
console.log(s.swim())
}
}
airShow([new Sparrow()])
poolShow([new Penguin()])
// airShow([new Penguin()]) would not even type-check.
Any Flyer can join
The payoff of honest interfaces: a new Eagle joins the air show with no risk — anything that implements Flyer truly flies, so airShow never needs to doubt its input.
Output: TypeScript file and captured output
interface Flyer {
fly(): string
}
class Sparrow implements Flyer {
fly(): string {
return "sparrow: flying low"
}
}
// Eagle is new — and it truly flies, so it substitutes safely.
class Eagle implements Flyer {
fly(): string {
return "eagle: soaring high"
}
}
function airShow(flyers: Flyer[]): void {
for (const f of flyers) {
console.log(f.fly())
}
}
airShow([new Sparrow(), new Eagle()])