Subtypes
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
has_cat(SealedBox("cat"))
#> 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).
Footnotes
-
Protocols were specified in PEP 544. The mypy documentation provides a good overview of their use. ↩
-
Importing collection types from
typing
was deprecated in PEP 585 with the introduction of generic types for standard collections. ↩