Typesspec

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.

The question

What does Dog <: Animal actually license the compiler to do, and what does it not check?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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?”

What this phase may assume or do

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 Rectangle type-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.

Subsumption, and where a real checker allows it
        Γ ⊢ 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

specJava specifies covariant return types for overrides (since 5.0) and requires that an override not throw broader checked exceptions, but specifies no behavioural obligations at all — the JLS has nothing corresponding to preconditions or invariants. C# and Kotlin are the same. Eiffel is the exception, specifying Design by Contract with inheritance rules for assertions; those are enforced at runtime with a compile-time flag, not by the type checker.

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/invariant clauses 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.

How the relation is established, and what that decidesspec
AspectNominalStructural
How S <: T is establishedBy declaration: extends, implements, impl … forBy shape: S has every member T requires, at compatible types
LanguagesJava, C#, C++, Kotlin, Swift classes, Rust traitsTypeScript, Go interfaces, OCaml objects, Python protocols
Accidental conformanceImpossiblePossible, and occasionally dangerous — one method name is enough
Adapting a third-party typeRequires a wrapper, or a trait impl if the language allows orphan-free extensionFree: it already conforms if the shape matches
Distinguishing same-shaped typesNatural: UserId and OrderId are different typesRequires branding — a phantom field or unique symbol — to fake nominality
Checking costimplementationA lookup in the declared hierarchyA 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

specJava specifies that an override must have the same parameter types; a differing parameter type produces an overload, not an override, and the compiler is not required to warn without @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.

Widening a parameter type
Before
function feed(d: Dog): void { d.eat() }
After
function feed(a: Animal): void { a.eat() }
Legal only when

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.

Illegal when

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 Square is passed where a Rectangle was 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 ClassCastException or a failed dyn_cast appears 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 instanceof chains 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 than extends 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 -c on a call site shows whether the compiler emitted invokevirtual on the base type; removing @Override and widening a parameter, then re-running, shows the silent overload directly in the bytecode.
  • C++: clang++ -Xclang -fdump-record-layouts shows the pointer adjustments multiple inheritance requires at an upcast — proof that an upcast is not always free.
  • TypeScript: tsc --generateTrace traceDir produces 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-sizes and the lifetime errors themselves; rustc --explain E0308 for the mismatch form and E0495 for the lifetime-subtyping form.
  • Go: go build -gcflags=-m reports 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 Rectangle is 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 <: float because 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.

The Liskov substitution principle is enforced by the type checker.
Its syntactic half is. The behavioural half — preconditions, postconditions, invariants — is not expressible in any mainstream type system and is enforced only by tests, or by contracts checked at runtime.
Structural typing is just weaker nominal typing.
It is a different relation with different failure modes. It makes third-party conformance free and accidental conformance possible, and it makes nominal distinctions like UserId versus OrderId require deliberate branding.
Rust has no subtyping.
It has subtyping over lifetimes, with full variance rules. Values are invariantly typed, which is what people usually mean, but the region relation is real and shows up in every non-trivial borrow error.
Adding a supertype is a safe, compatible change.
It can change overload resolution, trait selection and inference results at call sites you did not touch, and for an override it can convert the method into an overload.

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.

specJava specifies covariant return types for overrides and identical parameter types; a differing parameter type is an overload rather than an override, with no required diagnostic in the absence of @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.
specRust’s reference specifies subtyping over lifetimes: a longer region is a subtype of a shorter one, and variance rules determine how that propagates through references and containers. Rust is routinely described as having no subtyping, which is false in exactly this respect and accounts for a large share of its harder inference errors.
implementationTypeScript decides assignability structurally with extensive caching, and check time depends on type size and union cardinality rather than on hierarchy depth. Large unions and deeply recursive conditional types are the documented pathological cases, and --generateTrace exists specifically to find them. Nominal languages have no equivalent cost and different pathologies.
typicalThe claim that an upcast emits no code is true for single-inheritance references in mainstream implementations and false under multiple inheritance, where a pointer adjustment is required, and for interface values in Go, where an interface value is a two-word pair that must be constructed. Verify with a disassembly before relying on it in a hot path.

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 Rectangle a problem when it type-checks perfectly?
  • Give three sources of subtyping other than class inheritance.
  • Is int <: float subtyping? Defend your answer.

Connections

Domains that do not exist yet
  • Software Design — Composition over inheritance, and designing an interface people can implement
    Whether 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 interface
    The 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.