Subtyping: What `Dog <: Animal` Licenses
One rule — if `S <: T` then an `S` may appear wherever a `T` was demanded — and it applies to every expression, which is why adding it to a checker is a redesign rather than an addition. The compiler checks the signature; Liskov’s behavioural obligations are checked by nobody.
What does Dog <: Animal actually license the compiler to do, and what does it not check?
A *relation* over types, layered on top of the typing judgment. Once it exists, an expression no longer has one type: it has a type and, implicitly, every supertype of that type. The question the relation answers is not “what type is this” but “may a value of type S be used where a T was demanded?”
The subsumption rule may fire only where the relation genuinely holds, and the relation must be reflexive and transitive for the checker to be able to close over it without search. Beyond the syntactic rule sits an unchecked semantic obligation: if the S implementation violates a guarantee the T contract made — narrows a precondition, weakens a postcondition, breaks an invariant — the program type-checks and misbehaves. No mainstream compiler checks any of the three.
Key points
- Subtyping is one rule: if
S <: T, an expression of type S may be used wherever a T was demanded. - The rule has no expression form in its conclusion, so it matches every node — which is why a checker must restrict where it may fire rather than implement it literally.
- The relation must be reflexive and transitive, or the checker cannot close over it without search.
- Compilers check the syntactic half of Liskov: parameter types, return types, exceptions, member presence.
- They check none of the behavioural half: preconditions, postconditions and invariants are obligations no mainstream type system can express.
Square extends Rectangletype-checks perfectly and violates the contract. That gap is the practical limit of the feature.- Nominal subtyping needs a declaration; structural needs only a matching shape. The rule is identical; only the definition of
<:differs. - Inheritance is one source of the relation. Records, top and bottom types, unions, intersections and Rust lifetimes are others.
- Numeric widening is a conversion, not subtyping: it changes the representation and can lose precision.
One rule, applying everywhere
The entire content of subtyping in a type system is the subsumption rule. It says: if you can show an expression has type S, and S is a subtype of T, then you may also treat that expression as a T.
Read the rule and then notice the thing that makes it expensive. It has no expression form in its conclusion. Γ ⊢ e : T matches *any* e. Every other rule in [[typing-rules]] is keyed to a syntactic shape — an addition, an application, a conditional — and can therefore be selected by looking at the node. This one cannot. A checker implementing the rules literally would have to consider applying subsumption at every node, in every possible way, which is search rather than checking.
That is why adding subtyping is not an incremental change to a checker. It forces a redesign into an *algorithmic* rule set where subsumption is permitted only at specific positions — argument passing, assignment, return, and the branches of a conditional — with a theorem that the restricted system accepts the same programs. It is also the single reason [[hindley-milner]] cannot absorb subtyping: unification solves equalities, and subsumption turns every equality into an inequality.
Γ ⊢ e : S S <: T
────────────────────────── (T-Sub)
Γ ⊢ e : T
Read: "if e has type S, and S is a subtype of T,
then e also has type T."
The relation itself needs two properties, or the
checker cannot reason with it at all:
──────── (S-Refl) S <: U U <: T
T <: T ─────────────────── (S-Trans)
S <: T
ALGORITHMIC PLACEMENT — where checkers actually allow it:
passing an argument f(dog) where f wants Animal
assigning a = dog where a : Animal
returning return dog from -> Animal
joining branches if c then dog else cat : Animal
upcasting explicitly (dog as Animal)
and NOWHERE else, so that the rule set stays
syntax-directed and the checker stays deterministic.Liskov: the part the compiler checks, and the part it does not
Liskov’s substitution principle is usually quoted as a design maxim and is more useful read as two separate claims: one syntactic, which a compiler enforces, and one behavioural, which it does not.
The syntactic obligation is exactly T-Sub, plus the requirements on members: an override must accept at least what the overridden method accepted and return no more than it promised. Every statically checked object-oriented language enforces this. Java rejects an override that narrows a parameter type or widens a checked-exception set; C# and Kotlin do the same; @Override exists to catch the case where you meant to override and did not.
The behavioural obligation is the one Liskov actually wrote about, and it has three parts: a subtype may not strengthen a precondition, may not weaken a postcondition, and must preserve the supertype’s invariants. None of these is expressible in a mainstream type system, so none is checked.
The canonical demonstration is Square extends Rectangle. Rectangle offers setWidth and setHeight, and its implicit postcondition is that setting one does not change the other. Square must keep its sides equal, so setWidth also changes the height. Every signature matches. The compiler is entirely satisfied. And a function written against Rectangle that sets width to 5 and height to 4 and expects an area of 20 gets 16 when handed a Square. Nothing in any mainstream type system can catch this, which is worth stating plainly because it is the practical limit of the whole feature: subtyping is checked on shapes and used for behaviour.
- What is checked: parameter types, return types, thrown exception sets, member presence, access modifiers, and in some languages nullability and mutability.
- What is not checked: preconditions, postconditions, invariants, performance characteristics, thread-safety, whether the override calls
super, or whether it terminates. - Eiffel is the mainstream language that tried:
require/ensure/invariantclauses with inheritance rules that weaken preconditions and strengthen postconditions down the hierarchy — checked at runtime, not statically. - Refinement types (Liquid Haskell, F*) can express and check some of this statically, at the cost of an SMT solver in the compiler and errors reported as unsatisfiable constraints.
- The practical substitute is a shared test suite run against every implementation of an interface — a behavioural conformance suite, which is what contract tests do for the same problem across process boundaries.
Nominal and structural: two ways for the relation to hold
Where does Dog <: Animal come from? Two families of answer, and the choice shapes an entire ecosystem.
Nominal subtyping requires a declaration: class Dog extends Animal, impl Display for Dog, struct Dog : Animal. The relation holds because someone said so. Accidental conformance is impossible, name-based invariants are enforceable — a UserId and an OrderId that are both integers underneath are genuinely different types — and adding a supertype to an existing type requires editing that type.
Structural subtyping requires only shape: if a type has the right members with the right types, it qualifies. TypeScript, Go’s interfaces and OCaml’s object types work this way. Third-party conformance is automatic, adapting a type from another library needs no wrapper, and accidental conformance is a real hazard — a type with a Close() error method satisfies io.Closer whether or not closing is what it does.
The full treatment is [[structural-vs-nominal]]; what matters here is that the *rule* — T-Sub — is identical in both, and only the definition of <: differs. That is a useful thing to notice, because it means the algorithmic difficulties of subtyping are shared: both need reflexivity and transitivity, both interact with [[variance]] the same way, and both destroy principal types.
| Aspect | Nominal | Structural |
|---|---|---|
How S <: T is established | By declaration: extends, implements, impl … for | By shape: S has every member T requires, at compatible types |
| Languages | Java, C#, C++, Kotlin, Swift classes, Rust traits | TypeScript, Go interfaces, OCaml objects, Python protocols |
| Accidental conformance | Impossible | Possible, and occasionally dangerous — one method name is enough |
| Adapting a third-party type | Requires a wrapper, or a trait impl if the language allows orphan-free extension | Free: it already conforms if the shape matches |
| Distinguishing same-shaped types | Natural: UserId and OrderId are different types | Requires branding — a phantom field or unique symbol — to fake nominality |
| Checking costimplementation | A lookup in the declared hierarchy | A structural comparison, memoised, and potentially recursive — TypeScript caches aggressively for this reason |
| Error messages | “Dog does not implement Animal” | “Property bark is missing in type X but required in type Y” — more precise, and enormous for large types |
Where subtyping comes from besides inheritance
@Override. Rust specifies lifetime subtyping (variance over regions) in its reference, so &'static T is usable where &'a T is required. C++ allows covariant return types on virtual overrides and requires identical parameter types, with the same silent-overload hazard as Java.Class inheritance is the most visible source of the relation and far from the only one. Recognising the others is what turns subtyping from an object-oriented topic into a type-system one.
Record width and depth. { name: string, age: int } <: { name: string } — a record with more fields is usable where fewer were demanded. That is width subtyping, and it is the whole basis of structural typing. Depth subtyping goes further: { pet: Dog } <: { pet: Animal }, which is only sound if the field is immutable — the subject of [[variance]].
Top and bottom types. Any, Object, unknown sit above everything; Nothing, never, ! sit below everything. A bottom type is genuinely useful: it is the type of an expression that does not return, which is what lets throw appear in a branch of a conditional without disturbing the other branch’s type.
Union and intersection types. T <: T | U always, and T & U <: T always. These are subtyping relations by construction, and they are why [[union-types]] and [[intersection-types]] compose with the rest of the system rather than sitting beside it.
Lifetimes. In Rust, a longer lifetime is a subtype of a shorter one: &'static str <: &'a str. This is real subtyping in a language people describe as not having any, and it is where most confusing Rust lifetime errors actually come from — see [[lifetime-analysis]].
What is *not* subtyping, despite looking like it: numeric widening. int to float changes the representation, so it is a conversion the compiler inserts, not a claim that an int value is a float value. The distinction matters because conversions cost an instruction and can lose precision, while subsumption is free and lossless by definition.
function feed(d: Dog): void { d.eat() }function feed(a: Animal): void { a.eat() }Widening a parameter is a compatible change for callers only if the body uses nothing beyond the supertype’s interface — here, eat() must be declared on Animal — and if no overload resolution or trait selection at any call site changes as a result. Under those conditions every existing call still type-checks, because Dog <: Animal and the argument is subsumed.
When the body calls a Dog-only member, which is a compile error and therefore harmless; and, far more dangerously, when the function is an *override* rather than a standalone function. Widening an override’s parameter type in Java does not widen the override — it creates an overload, so the original method is no longer overridden and the base implementation runs instead, silently. This is the entire reason @Override exists, and the reason its absence is a warning worth treating as an error.
How it works
The steps, in the order the compiler takes them.
- The language defines a relation
<:— by declared hierarchy, by structural comparison, or by both — and the checker must be able to decide it. - Reflexivity and transitivity are required. A nominal hierarchy gets transitivity by walking up the declared chain; a structural one gets it by memoised recursive comparison.
- The checker restricts subsumption to argument positions, assignments, returns, conditional joins and explicit upcasts, so the rule set stays syntax-directed.
- At each such position it asks
Sactual <: Texpected, and reports a mismatch naming both types if not. - For generic types, the relation on the constructor is decided by
[[variance]]rather than by the relation on the arguments alone. - Overriding is checked separately: the override’s parameter types must match (or be contravariant where allowed), and its return type must be the same or a subtype.
- The back end typically emits nothing for an upcast on a reference — the value is unchanged. Multiple inheritance and interface dispatch are the exceptions, where a pointer adjustment or an interface-table lookup is required.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A
Squareis passed where aRectanglewas expected, every type checks, and an area calculation returns the wrong number with no error anywhere. - A method intended as an override has a widened parameter type, silently becomes an overload, and the base class implementation runs instead — the observable symptom is a feature that appears not to have been implemented.
- A value is upcast for storage and downcast on retrieval, the downcast is wrong, and a
ClassCastExceptionor a faileddyn_castappears at a line containing no visible type change. - In a structurally typed language, an unrelated object satisfies an interface because it happens to have a method of the right name, and the wrong implementation is dispatched with no diagnostic.
- A widened parameter type changes overload resolution at a call site, so a different function is selected and the behaviour changes with no edit at that call site — see
[[ad-hoc-polymorphism]]. - A deep or cyclic structural comparison makes the checker slow: one large TypeScript union compared against another turns a two-second check into a two-minute one.
- Subsumption discards information at the point of upcast, and a later branch needs it back — so the codebase accumulates downcasts and
instanceofchains that the type system was supposed to remove.
When it helps
- Writing code against an interface rather than an implementation, which is the mainstream mechanism for decoupling and the reason the feature exists.
- Understanding why a language rejects a container assignment that looks obviously fine — the answer is almost always
[[variance]], and subtyping is its prerequisite. - Diagnosing “my override is not being called”, which is nearly always a signature mismatch that turned it into an overload.
- Deciding between an interface and a sum type: subtyping makes adding an implementation easy and adding an operation hard, and closed sum types make the reverse true.
When it hurts
- When the behavioural obligation is violated. The compiler will not help, and the failure is a wrong answer rather than an error.
- When combined with mutation and generics, where the naive rules are unsound and every language’s patch is different —
[[variance]]. - In inference. Subtyping destroys principal types, which is why languages with it use bidirectional or candidate-based inference rather than
[[hindley-milner]], and why their inference errors are harder to localise. - When a deep hierarchy is used where composition would do. The type relation then encodes a taxonomy that the domain does not actually have, and every change to it ripples through the whole tree.
What it costs
Every one of these is paid by something.
- Subtyping buys interface-based decoupling and reuse, and pays by destroying principal types — every language with it needs a more complex, less complete inference algorithm and gives worse inference errors.
- A nominal relation buys the impossibility of accidental conformance and enforceable name-based distinctions, and pays in the inability to retrofit a supertype onto a type you do not own without a wrapper.
- A structural relation buys frictionless third-party conformance and pays in accidental matches, in branding workarounds when you need nominality, and in checking cost that grows with type size rather than with hierarchy depth.
- Allowing subsumption at many positions buys convenience and pays in a checker that must prove its restricted rule set complete, and in error messages that must explain which of several possible subsumptions failed.
- Deep hierarchies buy shared behaviour and pay in coupling: every supertype is a commitment that every subtype must keep, and the commitment is checked on signatures only.
What else you could do
What a different compiler or language does instead, and when that is better.
- Parametric polymorphism with constraints instead of a hierarchy:
<T: Comparable>rather thanextends Comparable. Rust and Haskell take this route, and it avoids the variance problem for values while raising it for references — see[[parametric-polymorphism]]. - Closed sum types with exhaustive pattern matching, which make adding an operation easy and adding a case a deliberate, checked change —
[[algebraic-data-types]]and[[exhaustiveness-checking]]. - Composition and explicit delegation, with no subtype relation at all: hold the collaborator, forward the calls. More typing, no inherited obligations, and Go’s embedding is the ergonomic version.
- Row polymorphism, as in OCaml’s polymorphic variants and PureScript’s records: express “a record with at least these fields” as a type variable over the remaining fields rather than as a subtype relation. Keeps principal types, at the cost of unfamiliar syntax.
- Refinement types, where the relation is “this type’s predicate implies that one’s”, checked by an SMT solver. Expresses the behavioural half of Liskov, and puts a solver in the compiler.
See it for yourself
The flag, dump or tool that shows you this directly.
- Java:
javap -con a call site shows whether the compiler emittedinvokevirtualon the base type; removing@Overrideand widening a parameter, then re-running, shows the silent overload directly in the bytecode. - C++:
clang++ -Xclang -fdump-record-layoutsshows the pointer adjustments multiple inheritance requires at an upcast — proof that an upcast is not always free. - TypeScript:
tsc --generateTrace traceDirproduces a profile of structural comparison, which is how you find the union type that is making a check slow. - Rust:
cargo rustc -- -Z print-type-sizesand the lifetime errors themselves;rustc --explain E0308for the mismatch form and E0495 for the lifetime-subtyping form. - Go:
go build -gcflags=-mreports where a concrete type was converted to an interface value, which is the moment structural conformance was checked and boxed. - The quickest behavioural check for a hierarchy: write one test suite against the supertype’s contract and run it against every subtype. Everything Liskov requires and the compiler skips is in that suite.
Plausible wrong readings
Stated the way a confident engineer states them.
- “Subtyping means inheritance.” Inheritance is one way to establish the relation. Records, unions, intersections, top and bottom types and Rust lifetimes all produce it without any class involved.
- “If it compiles, substitution is safe.” The compiler checked the signatures. Preconditions, postconditions and invariants were checked by nobody, and
Square extends Rectangleis the standing counterexample. - “An upcast is always free at runtime.” On a single-inheritance reference, yes. Under multiple inheritance the pointer must be adjusted, and an interface upcast in Go or Java may allocate or build an interface table.
- “Widening a parameter type is always backward compatible.” Not for an override, where it silently produces an overload and the base implementation runs instead.
- “
int <: floatbecause an int can be used where a float is expected.” That is a conversion with a representation change and possible precision loss, not subsumption. Subsumption never changes the value.
Misconceptions
The claim, and what is actually true.
UserId versus OrderId require deliberate branding.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Subtyping means one type can stand in for another: if Dog is a subtype of Animal, you can pass a Dog anywhere an Animal is wanted. That is the whole rule. The important caveat is what the compiler actually verifies — it checks that the shapes line up, that the methods exist with compatible signatures. It does not check that the Dog behaves the way code written for Animal expects. The standard example is a Square that extends Rectangle: setting its width also changes its height, every signature matches, the compiler is happy, and the arithmetic is wrong.
practical
Two things save time. First, when an override “is not being called”, check the signature — a differing parameter type turns an override into an overload, silently, in Java and C++ alike. Annotate every intended override and treat the missing-annotation warning as an error. Second, write your contract as a test suite against the interface and run it against every implementation. Everything Liskov requires and the compiler skips lives in that suite, and it is the only place a Square-shaped bug will be caught. When designing, prefer shallow hierarchies and composition: a supertype is a promise that every current and future subtype must keep, and the compiler will only check the easy half of it.
advanced
The reason subtyping is expensive to a compiler has nothing to do with hierarchies and everything to do with the shape of the rule. Every other typing rule is keyed to a syntactic form and so can be selected by inspection; subsumption is keyed to nothing and can apply anywhere. The standard resolution is to construct an algorithmic system where subsumption is folded into a fixed set of positions, then prove it accepts the same programs as the declarative one. That restriction is why languages differ in surprising places — whether subsumption applies inside a conditional’s branches, at a generic argument, at a lambda’s return — and why the answers are recorded in specifications rather than being obvious. Downstream, the same rule destroys the principal-types property, which forces inference to be bidirectional or candidate-based and makes inference errors harder to attribute. And composed with generics it produces [[variance]], where the naive rule is outright unsound and every mainstream language patched it differently. Subtyping is the clearest case in this domain of a feature that is one line to specify and reshapes every algorithm downstream of it.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
@Override. C++ specifies the same for virtual functions. This is a language rule and the source of a recurring class of silent bug in both.--generateTrace exists specifically to find them. Nominal languages have no equivalent cost and different pathologies.If you were asked this in an interview
- State the subsumption rule and explain why it is harder to implement than it looks.
- What does a compiler check when you override a method, and what does it not check?
- Why is
Square extends Rectanglea problem when it type-checks perfectly? - Give three sources of subtyping other than class inheritance.
- Is
int <: floatsubtyping? Defend your answer.
Connections
- Software Design — Composition over inheritance, and designing an interface people can implementWhether a relation should exist at all is a modelling decision. This lesson covers what the relation licenses the compiler to do and what it leaves unchecked; whether to create it is a design judgment.
- Testing & Reliability Engineering — Behavioural conformance suites for an interfaceThe behavioural half of Liskov is unenforceable by any mainstream type system, so it must be tested. Designing a suite that runs against every implementation is a testing subject.