Duck Hinting – Reconciling Python Type Hinting & Duck Typing

Type hinting and Duck Typing are both central parts of modern Python. How can we get them to work together?

Type Hinting

Type hinting allows us to indicate what types a function expects as arguments and what types it will return. E.g.

def get_greeting(name: str, age: int) -> str:

As the name makes clear, type hints are hints only but there are tools that enable type hinting to be checked and enforced.

Type hinting is still maturing in Python and more recent versions are less verbose and more readable e.g. int | float rather than typing.Union[int, float]

Initially I didn’t like type hinting. I suspected it was a costly and ritualistic safety behaviour rather than a way of writing better code. And people can certainly use type hinting like that. But I have changed my overall position on Python type hinting.

There are at least three ways to use type hinting in Python:

  • To improve readability and reduce confusion. For example, what is the following function expecting for the date parameter?

    def myfunc(date):

    Is it OK if I supply date as a number (20220428) or must it be a string (“20220428”) or maybe a datetime.date object? Type hinting can remove that confusion

    def myfunc(date: int):

    It is now much more straightforward to consume this function and to modify it with confidence. I strongly recommend using type hinting like this.

    The following is very readable in my view:

    def get_data(date: int, *, show_chart=False) -> pd.DataFrame:

    Note: there is no need to add : bool when the meaning is obvious (unless wanting to use static checking as discussed below). It just increases the noise-to-signal ratio in the code.

    def get_data(date: int, *, show_chart: bool=False) -> pd.DataFrame:

    On a similar vein, if a parameter obviously expects an integer or a float I don’t add a type hint. For example type hinting for the parameters below reduces readability for negligible practical gain:

    def create_coord(x: int | float, y: int | float) -> Coord:

    ## knowing mypy considers int a subtype of float
    def create_coord(x: float, y: float) -> Coord

    Instead

    def create_coord(x, y) -> Coord:

    is probably the right balance. To some extent it is a matter of personal taste.

    I hope people aren’t deterred from using basic type hinting to increase readability by the detail required to fully implement type hinting.
  • To enable static checks with the aim of preventing type-based bugs – which potentially makes sense when working on a complex code base worked on by multiple coders. Not so sure about ordinary scripts – the costs can be very high (see endless Stack Overflow questions on Type Hinting complexities and subtleties).
  • For some people I suspect type hinting is a ritual self-soothing behaviour which functions to spin out the stressful decision-making parts of programming. Obviously I am against this especially when it makes otherwise beautiful, concise Python code “noisy” and less readable.

Duck Typing

Python follows a Duck Typing philosophy – we look for matching behaviour not specific types. If it walks like a duck and quacks like a duck it’s a duck!

For example, we might not care whether we get a tuple or a list as long as we can reference the items by index. Returning to the Duck illustration, we don’t test for the DNA of a Duck (its type) we check for behaviours we’ll rely on e.g. can it quack?

There are pros and cons to every approach to typing but I like the way Python’s typing works: strong typing (1 != ‘1’); dynamic typing (defined at run-time); and duck typing (anything as long as it quacks).

Structural Type Hinting using Protocol

If we were able to blend type hinting with duck typing we would get something where we could specify accepted types based on the behaviours they support.

Fortunately this is very easy in Python using Protocol. Below I contrast Nominal Type Hinting (based on the names of types) with Structural Type Hinting (based on internal details of types e.g. behaviours / methods)

Full Example in Code

from typing import Protocol

class AttackerClass:
    def attack(self):
       pass

class Soldier(AttackerClass):
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Soldier {self.name} swings their sword!")

class Archer(AttackerClass):
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Archer {self.name} fires their arrow!")

class Catapult:
    def __init__(self, name):
        self.name = name
    def attack(self):
        print(f"Catapult {self.name} hurls their fireball!")

## only accept instances of AttackerClass (or its subclasses)
def all_attack_by_type(units: list[AttackerClass]):
    for unit in units:
        unit.attack()

s1 = Soldier('Tim')
s2 = Soldier('Sal')
a1 = Archer('Cal')
c1 = Catapult('Mech')

all_attack_by_type([s1, s2, a1])

## will run but not wouldn't pass static check
## because c1 not an AttackerClass instance
## (or the instance of a subclass)

## comment out next line if checking with mypy etc - will fail
all_attack_by_type([s1, s2, a1, c1])

class AttackerProtocol(Protocol):
    def attack(self) -> None:
        … ## idiomatic to use ellipsis

def all_attack_duck_typed(units: list[AttackerProtocol]):
    for unit in units:
        unit.attack()

## will run as before even though c1 included
## but will also pass a static check
all_attack_duck_typed([s1, s2, a1, c1])

But what if we cannot or should not modify an object by adding the required method to it directly e.g. code in library code? That is the topic of the next blog post.