Subtyping in Python: Nominal vs Structural Explained#

Twitter Handle LinkedIn Profile GitHub Profile Tag Tag Tag

When you pass a Dog to a function annotated to take Animal, mypy says nothing. Pass a list[int] to a function that wants list[float], and it objects. Both feel like “a smaller thing where a bigger thing is expected” — so why does one substitution type-check while the other fails?

The rule behind both verdicts is subtyping: the contract that lets a value of one type stand in wherever another type is expected, with the surrounding program none the wiser. This chapter builds that contract from first principles — types as sets of values, subtypes as substitutable subsets — and then shows the two ways Python establishes it: nominally, through class inheritance, and structurally, through duck typing and typing.Protocol.

By the end you can predict, before running a checker, whether one type may substitute for another; pick between inheritance and protocols for your own APIs; and spot the trap where a structural match violates the Liskov substitution principle — the rule that a subtype must behave like its supertype, not merely look like it. (The list refusal is variance, which the variance chapter resolves.)

Prerequisites

This is the first chapter of the series, and it assumes only comfort with Python classes and inheritance — no prior type theory. The roadmap and the series conventions (Python 3.14 syntax, every static-analysis claim verified against both mypy and pyright) live in the series introduction.

Types Are Sets of Values#

The fastest route into subtyping — especially for readers with a mathematical background — is to identify a type with a set of values: that is what a type is in type theory[1]. bool names a set with exactly two elements, True and False; str names the infinite set of all strings; and a value “has type T” precisely when it is an element of T’s set.

Example 57 (Integer Type as a Set)

To illustrate, consider the Integer (int) type in many programming languages. You can think of this type as a set that includes all whole numbers from negative infinity to positive infinity. Each such number is an element of the Integer type.

Once types are sets, subtyping nearly defines itself: a subtype is a type whose set of values sits inside the set of another type — its supertype — in a way that lets every subtype value stand in for a supertype value. We write \(S <: T\) for “\(S\) is a subtype of \(T\)”; read it as “an \(S\) can stand in for a \(T\)[2].

Definition 199 (Subtype)

A type \(S\) is a subtype of a type \(T\), written \(S <: T\), when both of the following hold[3]:

  1. Values: every value of \(S\) is also a value of \(T\) — as sets, \(S \subseteq T\).

  2. Substitutability: every program element (typically a function or subroutine) written to operate on values of \(T\) also works, with unchanged meaning, on values of \(S\).

\(T\) is then called the supertype of \(S\), and the relation \(<:\) is what a type checker consults whenever a value of one type appears where another type is expected.

Clause 1 is the subset picture. Clause 2 carries the weight: “works” means behaves as the supertype’s contract promises, not merely “does not raise”. That behavioral fine print is the Liskov substitution principle from the opening, stated formally in the LSP theorem, and Subsumption sharpens both clauses into a checkable, three-part subtype criterion. Subtyping is also called subtype polymorphism or inclusion polymorphismpolymorphism (“many forms”) because one function written against \(T\) operates, unchanged, on values of every subtype of \(T\).

The Two Subtyping Schemes: Nominal and Structural#

Knowing what a subtype is does not yet tell a type checker how to decide that \(S <: T\) holds for two concrete types. Type systems answer with one of two subtyping schemes — and Python, unusually, offers both:

  • Nominal subtyping bases the relationship on explicit declarations: \(S <: T\) holds only if the code says so — in Python, class S(T). Names and declared lineage decide.

  • Structural subtyping bases the relationship on shape: \(S <: T\) holds if \(S\) supplies every member (method or property) that \(T\) requires, whether or not the two classes have ever heard of each other.

Definition 200 (Nominal and Structural Subtyping)

Let \(S\) and \(T\) be types.

  1. Under nominal subtyping, \(S <: T\) holds if and only if the program explicitly declares \(S\) a subtype of \(T\) — in Python, T appears in the inheritance chain (the method resolution order) of S.

  2. Under structural subtyping, \(S <: T\) holds if and only if \(S\) provides every member that \(T\) requires, with compatible signatures.

The distinction matters most to static type checkers — tools such as mypy and pyright that read source and flag type errors at static-analysis time, before the program ever runs. Asked whether \(S <: T\), a nominal check walks the declared class hierarchy looking for T among S’s ancestors; a structural check compares members, asking whether S could fulfill T’s obligations regardless of ancestry.

When is the relationship established — and when is it checked?

A nominal subtype relationship is established at declaration time (the moment class Dog(Animal) is written) and checked at static-analysis time by walking the declared hierarchy — or at runtime by isinstance. A structural relationship is never declared: it is established implicitly at the point of use, and classic duck typing checks it only at runtime. typing.Protocol (PEP 544, in the standard library since Python 3.8 — a language feature, not a mypy one, so any type checker understands it) is precisely the device that lifts the structural check to static-analysis time. Both timings are demonstrated below.

Nominal Subtyping: Subtypes by Declaration#

Nominal subtyping is the conservative scheme: a type is a subtype of another only if it is explicitly declared as such. In Python — as in Java and C# — the declaration is class inheritance (or, in interface-based languages, an explicit implements clause). class Dog(Animal) is the declaration; there is no way for Dog to become a nominal subtype of Animal after the fact, and no way to do it by accident.

That explicitness is the scheme’s selling point. Subtype relationships are planned in advance and visible in the source, so the hierarchy is a controlled environment for polymorphism: readers can trace the lineage, and the author of a superclass can design for — and document — the behavioral obligations the Liskov substitution principle places on every subclass. The cost is rigidity, which the structural scheme exists to relieve.

The cell below is the canonical fixture for this series; later chapters reuse it. Dog and Cat inherit from Animal, while Robot copies Animal’s methods exactly but declares no lineage.

 1# Canonical fixture for this series — reused by later chapters
 2class Animal:
 3    def describe(self) -> str:
 4        return str(self.__class__.__name__)
 5
 6    def make_sound(self) -> str:
 7        return "Generic Animal Sound!"
 8
 9
10class Dog(Animal):
11    def make_sound(self) -> str:
12        return "Woof!"
13
14    def fetch(self) -> str:
15        return "Happily fetching balls!"
16
17
18class Cat(Animal):
19    def make_sound(self) -> str:
20        return "Meow"
21
22    def how_many_lives(self) -> str:
23        return "I have 9 lives!"
24
25class Robot:
26    def describe(self) -> str:
27        return str(self.__class__.__name__)
28
29    def make_sound(self) -> str:
30        return "Generic Robot Sound!"
31
32cat = Cat()
33dog = Dog()
34rob = Robot()
35print(isinstance(cat, Animal))  # True,  Cat is a nominal subtype of Animal
36print(isinstance(dog, Animal))  # True,  Dog is a nominal subtype of Animal
37print(isinstance(rob, Animal))  # False, Robot is not a nominal subtype of Animal
True
True
False

Dog and Cat are nominal subtypes of Animal because they explicitly inherit from it. Robot has the exact same method signatures as Animal — under a structural reading it would qualify — but it declares no lineage, so under the nominal scheme it is simply not an Animal, and isinstance agrees. Note, too, that Python happily lets a subclass override methods unsafely; nothing at runtime stops Dog.make_sound from returning an int. It is the static type checker that polices such violations of the Liskov substitution principle, and Type Safety stages exactly that crash with a misbehaving Robot variant.

Structural Subtyping: Subtypes by Shape#

Structural subtyping is the liberal scheme: a type is a subtype of another based on its structure — it possesses every member (property and method) the supertype requires — regardless of what it inherits from. It is the typed rendering of duck typing: if an object walks like a duck and quacks like a duck, treat it as a duck.

Why would you want this? Loose coupling. Structural subtyping lets classes that share no ancestor — often classes from libraries that cannot know about each other — interoperate the moment their shapes line up, enabling novel and unintended uses of existing code. Forcing every such relationship through a nominal base class would add ceremony and coupling without adding safety.

Duck Typing at Runtime: Sized and __subclasshook__#

Consider a toy example: a generic Dataset holding a Sequence of elements of type T (the class Dataset[T]: type-parameter syntax comes from PEP 695 — we unpack it properly in Generics). The class declares no lineage at all, so checking it against Sized — the standard library’s “has a __len__” interface — reports False.

1class Dataset[T]:
2    def __init__(self, elements: Sequence[T]) -> None:
3        self.elements = elements
4
5dataset = Dataset([1, 2, 3, 4, 5])
6print(isinstance(dataset, Sized))
False

However, once we add __len__, the very same check reports True. Sized requires just one thing — a __len__ method returning the size of the container — and despite Dataset inheriting from nothing, the mere presence of that method satisfies the structural expectation of being “sized”.

 1class Dataset[T]:
 2    def __init__(self, elements: Sequence[T]) -> None:
 3        self.elements = elements
 4
 5    def __len__(self) -> int:
 6        """Returns the number of elements in the collection."""
 7        return len(self.elements)
 8
 9dataset = Dataset([1, 2, 3, 4, 5])
10print(isinstance(dataset, Sized))
True

How did that work with no inheritance in sight? Sized is not a typing.Protocol (those arrive below); it is an abstract base class (ABC) that performs older structural typing dark magic: its __subclasshook__ classmethod inspects any candidate class the moment isinstance or issubclass asks.

Listing 1 Reproduced from CPython’s Lib/_collections_abc.py (quoted source; annotations added for exposition)#
 1class Sized(metaclass=ABCMeta):
 2
 3    __slots__ = ()
 4
 5    @abstractmethod
 6    def __len__(self):
 7        return 0
 8
 9    @classmethod
10    def __subclasshook__(cls: type[Sized], C: type) -> bool:
11        if cls is Sized:
12            return _check_methods(C, "__len__")
13        return NotImplemented

The highlighted hook makes Sized accept any class for which _check_methods(C, "__len__") succeeds — the helper walks the candidate’s method resolution order looking for a __len__ definition. To this end, Dataset is a structural subtype of Sized, and the check happens at runtime, inside isinstance itself. Hold on to _check_methods; it returns in the final section with less charming consequences.

Why Static Checkers Cannot See a Duck Check#

For pedagogical purposes, we can illustrate structural subtyping by implementing it manually. Our is_flyable function checks that an object has a fly attribute and that the attribute is callable — so we know it is a method or function, not a data attribute.

 1def is_flyable(obj: Any) -> bool:
 2    return hasattr(obj, "fly") and callable(obj.fly)
 3
 4class Bird:
 5    def fly(self) -> str:
 6        return "Bird flying"
 7
 8class Airplane:
 9    def fly(self) -> str:
10        return "Airplane flying"
11
12class Car:
13    def drive(self) -> str:
14        return "Car driving"
15
16print(is_flyable(Bird()))       # True, because Bird implements a callable fly method
17print(is_flyable(Airplane()))   # True, Airplane also implements a callable fly method
18print(is_flyable(Car()))        # False, Car does not implement a callable fly method
19
20objects = [Bird(), Airplane(), Car()]
21for obj in objects:
22    if is_flyable(obj):
23        print(f"{obj.__class__.__name__} can fly: {obj.fly()}")
24    else:
25        print(f"{obj.__class__.__name__} cannot fly.")
True
True
False
Bird can fly: Bird flying
Airplane can fly: Airplane flying
Car cannot fly.

The cell runs happily — at runtime the duck check does its job. But watch what happens when we hand the same code to the static type checkers. The guard is_flyable returns a plain bool, which tells a checker nothing about obj inside the if branch, and the heterogeneous list gives the two checkers room to disagree about obj itself. mypy --strict joins the element type of objects up to object (the only common ancestor of Bird, Airplane, and Car) and rejects the call:

duck_check.py:26: error: "object" has no attribute "fly"  [attr-defined]
Found 1 error in 1 file (checked 1 source file)

pyright, in its default mode, infers the element type as Unknown (an implicit Any) and stays silent — 0 errors, 0 warnings, 0 informations — though its strict mode flags the unknown-ness instead. Neither checker understands the duck check; they differ only in how loudly they shrug. (Teaching a checker to trust a boolean predicate is possible, but it must be declared with TypeIs or TypeGuard — the subject of a later chapter.)

Protocols: Structural Subtyping at Static-Analysis Time#

This gap is precisely what typing closes. By defining a protocol via the Protocol class, you can specify the required methods and properties for a type — making the structural relationship visible at static-analysis time:

 1from typing import Protocol
 2
 3class Flyable(Protocol):
 4    def fly(self) -> str:
 5        ...
 6
 7def can_we_fly(obj: Flyable) -> None:
 8    ...
 9
10bird = Bird()
11airplane = Airplane()
12car = Car()
13
14can_we_fly(bird)       # OK: Bird is a structural subtype of Flyable
15can_we_fly(airplane)   # OK: Airplane is a structural subtype of Flyable
16can_we_fly(car)        # runs fine at runtime; rejected at static-analysis time
17print("All three calls executed without a runtime error.")
All three calls executed without a runtime error.

Here, both Bird and Airplane are considered structural subtypes of the Flyable protocol because they implement the required fly method, even though they don’t explicitly inherit from Flyable. The Car class, on the other hand, does not implement the fly method and is not considered a structural subtype of Flyable.

Notice that the cell above executes without a single complaint — annotations are not enforced while the program runs, so even can_we_fly(car) sails through at runtime. The rejection happens at static-analysis time: save the Bird/Airplane/Car definitions together with the cell above as flyable.py and run a static type checker over it, and the car call — and only the car call — is flagged. pyright reports

flyable.py:34:12 - error: Argument of type "Car" cannot be assigned to parameter "obj" of type "Flyable" in function "can_we_fly"
    "Car" is incompatible with protocol "Flyable"
      "fly" is not present (reportArgumentType)
1 error, 0 warnings, 0 informations

and mypy --strict agrees:

flyable.py:34: error: Argument 1 to "can_we_fly" has incompatible type "Car"; expected "Flyable"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Runtime Protocol Checks with @runtime_checkable#

If you want to ensure that the check is done at runtime with isinstance, you can use the decorator runtime_checkable to enable runtime instance checks[4] (you cannot call isinstance on Flyable without this decorator):

 1from typing import Protocol, runtime_checkable
 2
 3@runtime_checkable
 4class Flyable(Protocol):
 5    def fly(self) -> str:
 6        ...
 7
 8print(isinstance(bird, Flyable))        # True, Bird is a structural subtype of Flyable
 9print(isinstance(airplane, Flyable))    # True, Airplane is a structural subtype of Flyable
10print(isinstance(car, Flyable))         # False, Car is not a structural subtype of Flyable
True
True
False

When Structural Subtyping Backfires: The LSP#

Nominal and structural subtyping establish the same promise by different tests, and the structural test has a blind spot: it evaluates the presence and signatures of members, not their meaning. Any class that happens to match an interface’s shape becomes its subtype — including classes that satisfy the letter of the contract while violating its spirit. That is precisely the failure the Liskov substitution principle names: substituting such a “subtype” changes what the program observably does, breaching the LSP theorem stated in Subsumption.

The fixture already contains the culprit. Under the nominal test, Robot was not an Animal. Give Animal a structural test instead — the same __subclasshook__ device Sized uses — and watch the verdict flip. First, the helper CPython’s ABCs rely on, reproduced with type annotations added:

 1from abc import ABCMeta
 2from types import NotImplementedType
 3
 4
 5def _check_methods(C: type, *methods: str) -> bool | NotImplementedType:
 6    mro = C.__mro__
 7    for method in methods:
 8        for B in mro:
 9            if method in B.__dict__:
10                if B.__dict__[method] is None:
11                    return NotImplemented
12                break
13        else:
14            return NotImplemented
15    return True

_check_methods walks the candidate’s method resolution order and reports whether every requested method is defined somewhere along it — True for a match, NotImplemented for “no verdict, fall back to the usual check”. Now the hook goes onto Animal. One detail is load-bearing: __subclasshook__ is consulted only by ABCMeta.__subclasscheck__, so the class must be built with metaclass=ABCMeta — on a plain class the hook is silently ignored, and issubclass behaves as if it were never written. (The cell deliberately redefines the fixture’s Animal; Dog, Cat, and Robot are untouched.)

 1class Animal(metaclass=ABCMeta):
 2    def describe(self) -> str:
 3        return str(self.__class__.__name__)
 4
 5    def make_sound(self) -> str:
 6        return "Generic Animal Sound!"
 7
 8    @classmethod
 9    def __subclasshook__(cls, C: type) -> bool | NotImplementedType:
10        if cls is Animal:
11            return _check_methods(C, "describe", "make_sound")
12        return NotImplemented
1print(issubclass(Robot, Animal))  # True — structurally admitted
2print(isinstance(rob, Animal))    # True — the same rob that failed the nominal test
True
True

The same Robot — the very instance that printed False at the top of the page — is now a subtype of Animal, because the structural test asks only whether describe and make_sound exist. Semantically the classification is nonsense: a robot is not an animal, and downstream code that assumes animal behavior will now cheerfully accept one. The signatures match, the semantics do not, and the LSP is violated without a line of inheritance in sight.

In practice the cure is design, not tooling: keep protocols small and behavior-focused, name them for the capability they demand (Sized, Flyable), and never use a shape as a proxy for a semantic category. Go — a language built almost entirely on structural subtyping — has evolved exactly these conventions, and Effective Go’s interface guidance codifies them.

Inclusive vs Coercive Implementations#

While nominal and structural subtyping focus on how type relationships are defined, inclusive and coercive implementations concern themselves with what happens to a value when types interact in a program[5]. In an inclusive implementation, the internal representation of a subtype value is already a valid representation of the supertype value, so nothing needs to change or be converted — think of it as direct “plug-and-play”. A Dog object passed to a function expecting an Animal is not transformed into an Animal; it is simply used, because its representation already includes all necessary aspects of an Animal. This is the reading that pairs naturally with the subtyping schemes above: every value of the subtype \(\mathcal{A}\) is a value of the supertype \(\mathcal{B}\).

In a coercive implementation, the internal representations differ, and the language inserts an “adapter”: the value is automatically converted before it is used. The canonical example is numeric. In 5 + 2.5, the int value 5 is implicitly converted to the float value 5.0 before the addition — int and float have different internal representations in CPython — and the result 7.5 is a float. The integer is not used as a float; it is turned into one.

Remark 77 (Coercion is a conversion function)

Formally, a coercive implementation between two types \(\mathcal{A}\) and \(\mathcal{B}\) (not necessarily subtypes of each other) supplies a conversion function

\[ f: \mathcal{A} \rightarrow \mathcal{B} \quad \text{such that} \quad \forall \mathcal{V}_{\mathcal{A}} \in \mathcal{A}, \exists \mathcal{V}_{\mathcal{B}} \in \mathcal{B} : \mathcal{V}_{\mathcal{B}} = f(\mathcal{V}_{\mathcal{A}}), \]

which the language applies implicitly wherever a \(\mathcal{B}\) is expected but an \(\mathcal{A}\) is supplied — in 5 + 2.5, \(f\) is the int-to-float conversion. An inclusive implementation is the degenerate case where \(f\) is the identity. Note that coercion is a statement about runtime values, not about the subtype relation itself: whether Python’s int should count as a subtype of float at static-analysis time is a different (and subtler) question, which we take up in Type Safety.

Summary#

If I had to compress this chapter into a single sentence, it would be the contract we opened with: a subtype is a type whose values can stand in for values of its supertype without the surrounding program noticing. Types are sets of values, and subtyping is a substitutability promise over those sets — every program element written to operate on the supertype must keep working when handed the subtype. Nominal and structural subtyping are not two different promises; they are two different ways of establishing the same promise. And as the Robot example showed, the structural route can extend the promise to types that match an interface’s letter while violating its spirit, which is why the Liskov substitution principle remains the semantic yardstick behind both schemes.

Nominal subtyping

Structural subtyping

Relationship established

By explicit declaration (class Dog(Animal):)

By shape — the required methods and properties are present

Declared where

At declaration time, in the class definition

Nowhere — it holds implicitly at the point of use

Python mechanism

Class inheritance

typing.Protocol (PEP 544); ABCs via __subclasshook__

Static-analysis check

Walks the declared class hierarchy

Matches members against the protocol

Runtime check

isinstance(obj, Animal)

isinstance only via @runtime_checkable or __subclasshook__

Characteristic risk

Rigidity — conformant-but-unrelated types excluded

Accidental conformance — semantically wrong subtypes slip in (LSP)

The next two chapters make the promise precise: Type Safety examines what can go wrong when substitution is allowed — and why the static type checker exists to stop it — while Subsumption states the formal criterion a checker applies when it lets a subtype stand in for its supertype. And the opening’s other refusal — a list[int] where a list[float] is wanted — is deliberately not settled here: that is variance, the subject of Invariance, Covariance and Contravariance.

References and Further Readings#