Subtypes of a type are also assignable to that type.

The unions that we've already seen are a straightforward example of subtyping. Union creates a new type whose subtypes are the members of the union.

a: str | None = None
a = "hello"

There are two ways in which the type checker determines subtypes: nominally and structurally.

Nominal Subtyping

Subtypes can be determined via class hierarchy. This is called nominal subtyping.

class Animal: pass
class Cat(Animal): pass

pet: Animal = Cat()
reveal_type(pet)  #> Cat

Because Cat subclasses Animal, it is a subtype of Animal. Note that despite the type hint of Animal, the inferred type of pet is still the narrower type Cat.

Nominal subtyping does not check that the attributes and methods of the subclasses are compatible with the parent class. To help you avoid runtime errors, Pyright will remind you when your subclass implementation is incompatible with the parent.

class Animal:
    def speak(self) -> str:
        return "hello"

class Cat(Animal):
    def speak(self) -> None:
        return None
#> Method "speak" overrides class "Animal" in an incompatible manner
#>    Return type mismatch: base method returns type "str", override returns type "None"

pet: Animal = Cat()

Structural Subtyping

Structural subtyping is the static equivalent of duck typing. Structural type checking is implemented via Protocol.1 For a class to be a structural subtype, it must implement all of the methods and properties of the protocol.

Let's create a protocol Container which specifies objects that support the in operator. Now we can create a function has_cat that is not tied to a specific class hierarchy:

from typing import Protocol

class Container(Protocol):
    def __contains__(self, x: object) -> bool:

def has_cat(container: Container) -> bool:
    return "cat" in container

The ... is a Python object called Ellipsis, used here to denote that the function is not actually being implemented (similar to pass). The function has_cat should work with any class that supports in: lists, dicts, and even our custom class Box which only holds one value:

class Box:
    def __init__(self, contents):
        self.contents = contents

    def __contains__(self, x) -> bool:
        return self.contents == x

has_cat(["dog"])         #> False
has_cat({"cat": True })  #> True
has_cat(Box("cat"))      #> True

A type error results when we attempt to use a class which doesn't support in.

class SealedBox:
    def __init__(self, contents):
        self.contents = contents

#> Argument of type "SealedBox" cannot be assigned to parameter "container" of type "Container" in function "has_cat"
#>   "SealedBox" is incompatible with protocol "Container"
#>     "__contains__" is not present

Classes do not need to inherit from the protocol to pass the type check. However, it is possible to inherit from a protocol, which surfaces type errors at the class definition:

class SealedBox(Container):
    def __init__(self, contents):
        self.contents = contents
#> Class derives from one or more protocol classes but does not implement all required members
#>   Member "__contains__" is declared in protocol class "Container"

Predefined Protocols

Several protocols are already implemented for us, for example Collection protocol we implemented above could simply be imported.

These protocols used to live in the typing module, but have been deprecated2 in favor of the collections.abc module. The abstract base classes in collections.abc have been specially extended to also function as protocols.[^abc]

from collections.abc import Container

def has_cat(container: Container) -> bool:
    return "cat" in container

Note: Other abstract base classes do not function as protocols, and subtypes must inherit from the abstract base class (a little confusing, yes).


  1. Protocols were specified in PEP 544. The mypy documentation provides a good overview of their use.

  2. Importing collection types from typing was deprecated in PEP 585 with the introduction of generic types for standard collections.