Python has a type system, but where are the interfaces???

2021-11-16
4 min read

TLDR: Protocols were introduced in Python 3.8, introducing support for structural subtyping. This fits super well in duck-typed language like Python, allowing for the creation of interfaces without requiring inheritance.

A brief aside about type hinting in Python

I find working with type-hinted Python to be a joy. I can easily read a function signature and know exactly what parameters it expects and what it returns. I can avoid a whole class of errors without running my code. Working with type-hinted code also increases my productivity by enabling intelligent tooling. Modern tooling like LSPs, IDEs, and static type checkers can make working with typed Python a dream by providing powerful refactoring tools, IntelliSense, and spell check. These things ultimately shorten the development feedback cycle. In the rare cases the type checker feels too restrictive, it can be easily disabled via the Any escape hatch - you can then just write normal Python. Type hinting brings the most value when navigating a large codebase or onboarding new developers into a project.

However, not everyone loves having type hints in their Python. Some argue that adding types to Python makes code harder to read and longer to write. I disagree with the assertion that type-hinted code is harder to read. Adding types is often an excellent way to signal the intent of code, making it easier for others to read by making the code more explicit rather than implicit.

The problem

I will admit that adding types can promote writing increased boilerplate if you go about it the wrong way. For example, I recently ran into this problem when I tried to type Python objects to ensure that any items added to a list implemented a specific method. That way, I could quickly iterate through the array and call the method on each item without checking to see if that method existed in each of the objects in the array.

I essentially wanted to use an interface, but since earlier versions of Python didn’t natively support interfaces, my first attempt led to me write a “fake interface” via abstract base class using the ABC module. I would then have objects to be pushed to the array implement the new abstract base class. This felt overly verbose and required me to add a lot of cruft just so I could support this type check 🤮.

Let’s look at this contrived example here:

class Dog:
 	def speak(self):
		print("woof!")

class Cat:
 	def speak(self):
		print("meow!")

class Plant:
 	def grow(self):
		print("I'm growing!")

def pet_animals(animals):
	for item in animals:
		print(item.speak())

list_of_things_to_speak = []
list_of_things_to_speak.append(Dog())
list_of_things_to_speak.append(Cat())
# We should not be able to append Plant to the list
list_of_things_to_speak.append(Plant()) # Oh no, this was not caught

pet_animals(list_of_things_to_speak) # Runtime error, plant cant speak!

Without types, it is hard to enforce that only objects that have a speak method can be added to the list.

We can add types to this code using the approach I mentioned above by using ABC’s and adding a type hint to the array. This will allow the MyPy type checker to catch this bug before runtime!

from abc import ABC, abstractmethod
from typing import List

class Speakable(ABC):
    @abstractmethod
    def speak(self):
        raise NotImplementedError('speak must be defined to use this base class')

class Dog(Speakable):
    def speak(self):
        print("woof!")

class Cat(Speakable):
    def speak(self):
        print("meow!")

class Plant:
    def grow(self):
        print("I'm growing!")

def pet_animals(animals):
    for item in animals:
        print(item.speak())

list_of_things_to_speak: List[Speakable] = []
list_of_things_to_speak.append(Dog())
list_of_things_to_speak.append(Cat())
# MyPy will catch this before runtime
list_of_things_to_speak.append(Plant()) # error: Argument 1 to "append" of "list" has incompatible type "Plant"; expected "Speakable"

While this works, it feels like we’re writing a lot of code to just satisfy the type checker. Also, we’ve introduced inheritance that did not exist before.

The solution!

The solution to this is to use the Python protocol. Released with Python 3.8, this is the native way of adding an interface.

from typing import List, Protocol

class Speakable(Protocol):
    def speak(self):
        pass

class Dog:
    def speak(self):
        print("woof!")

class Cat:
    def speak(self):
        print("meow!")

class Plant:
    def grow(self):
        print("I'm growing!")

def pet_animals(animals):
	for item in animals:
		print(item.speak())

list_of_things_to_speak: List[Speakable] = []
list_of_things_to_speak.append(Dog())
list_of_things_to_speak.append(Cat())
# MyPy will catch this before runtime
list_of_things_to_speak.append(Plant()) # error: Argument 1 to "append" of "list" has incompatible type "Plant"; expected "Speakable"

The main thing to notice here is that neither the Dog nor Cat classes implement the protocol directly. Just by sharing the same methods as the Speakable protocol, they are implicitly a subtype. Any future classes that are added that have a speak method will automatically be considered a subtype as well.

The use of protocols deomonstrates structural subtyping - classes are considered subtypes of a protocol by just sharing the same structure as the protocol without directly inheriting it. This is different from the nominal subtyping we originally used with our ABC class implementation, where inheritance from a specific interface is needed to be considered a subtype. If you’ve ever written Typescript, this should feel familiar as it is identical to how Typescript interfaces work.

Structural subtyping is a perfect fit for the duck-typed nature of python - bringing the benefits of typing without the overhead of inheritance.