While type-annotating your Python code, at some stage you might have felt that for a certain object in a given scope, you are not so much interested in its type as you are in its behaviour.

You don't really mind if it's a Duck, a Goose1, or even a PersonDisguisedAsADuck, you (and your program) only care about its ability to .quack().

What could you do to support static type checking in this case?

A naive solution

You could go for an abstract BaseQuacker:

from abc import ABC, abstractmethod

class BaseQuacker(ABC):
    @abstractmethod
    def quack(self) -> None:
        pass

and make Duck, Goose and friends inherit from BaseQuacker. Now you can annotate your object-of-interest as a BaseQuacker and let the gods of polymorphism do their work:

class Duck(BaseQuacker):
    def quack(self) -> None:
        print("quaack")

class Goose(BaseQuacker):
    def quack(self) -> None:
        print("hoooonk")

def make_it_quack(quacker: BaseQuacker) -> None:
    # ...
    quacker.quack()

make_it_quack(Duck())  # Passes static type check

What you are actually doing here is relying on nominal subtyping for your type checking.

Now think carefully, we have gone through the hassle of creating a base abstract class and making multiple classes inherit from it just to be able to type-check a method. Are you gonna leave that untested? - Nervous laughter intensifies.

Could we be more explicit and faithful to our original intent, that is expressing "hey, please check this thing here can quack", without unnecessarilly increasing the complexity of our code and potentially introducing bugs?

Protocols!

Python's typing module and Mypy provide support for structural subtyping.

Structural subtyping can be seen as a static equivalent of duck typing.

-- Mypy documentation

By annotating an object with a Protocol subtype you are just telling the type checker: "hey, this object should at least have the behaviour (methods) specified by the protocol". 2

In fact, Python provides a handful of built-in protocols (you may have even used them already), such as Iterable[T] for classes that are expected to implement an __iter__ method that yields objects of type T, for instance.

Back to our example, we would declare a Quacker protocol that enforces the presence of a quack method:

from typing import Protocol

class Quacker(Protocol):
    def quack(self) -> None:
        pass

Now we need not use inheritance on Duck, Goose and friends, ending up with something like:

class Duck:  # no inheritance!
    def quack(self) -> None:
        print("quaack")

class Goose:  # no inheritance!
    def quack(self) -> None:
        print("hoooonk")

def make_it_quack(quacker: Quacker) -> None:
    # ...
    quacker.quack()

make_it_quack(Goose()) # Passes static type check

Warning

If you want to use protocols beyond type checking, e.g. at runtime with isinstance(), you should decorate the Protocol class definition with the typing.runtime_checkable() decorator.3 Furthermore:

Protocol classes decorated with runtime_checkable() act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures.

-- Python documentation

Yikes. So be careful in this case!

Conclusion

Python's typing.Protocol together with Mypy provides you the ability of type-checking your code with structural subtyping.

This is useful when you are interested in annotating an object according to a specific behaviour, or by abuse of notation: when you want to do static duck typing.4

If you want to use protocols beyond type-checking, i.e., at runtime, you need to use the typing.runtime_checkable() decorator, but this comes with shortcomings (checking just for method existence, not signature).

You can find a handful of built-in protocols here.


  1. In this post I'm assuming geese quack, don't @ me. 

  2. In the context of type annotation, I think of typing.Protocol as an equivalent to the interfaces of languages like C# (take with a grain of salt - remember, they are just type hints). 

  3. https://docs.python.org/3/library/typing.html#typing.runtime_checkable 

  4. This could have been the title but I feared receiving threats from theorists. Jk, who cares.