Static Duck Typing.

Tags: Python, Types, Advanced Python

I’ve been using writing libraries and tooling for others recently and one of the most useful tools in this has been gradual typing. In the libraries that have been built we’ve fully typed them, allowing our users to make the decision of opting into type checking or not depending upon their needs.

As we don’t know what objects are users are going to be passing into our consuming functions we need a way of defining what properties our objects need ahead of time without knowing what the object will be. This is static duck typing; or to use it’s correct name as per the PEP, structural subtyping.

Let’s get stuck in with some code to give you a proper idea of what it is…

from dataclasses import dataclass
from datetime import datetime
from uuid import UUID, uuid4

from typing import TypeVar, Protocol, reveal_type


@dataclass
class Person:
    id: UUID
    name: str


def record_visit(person: Person) -> tuple[Person, str]:
    timecard = f"Visitor: {person.id}, Time: {datetime.utcnow()}"
    # We'd store our visit here or log it out. Let's just ignore this in future.
    # store_timecard(timecard, ...)
    return person, timecard

Pretty simple code. We’ve got a library with one function that enables us to record the visits of a person. We’re generating a timecard that specifies the persons name and the current utc timestamp. The function returns both the timecard and the person argument1.

Now, let’s say we’re extending our library and we’re now handling an Artwork object that looks like this.

@dataclass
class Artwork:
    id: UUID
    name: str
    medium: Medium

Now we want to record the visits of artwork to our museum in the same way. Now in the classical Python world this isn’t a problem, our Artwork object implements the required properties (id is all that’s required) so can be passed in without any changes. This is the wonder of duck typing, no changes required.

This doesn’t work when we’re offering types for the library though… So in come generics.

So we can use the TypeVar callable to define a new type var. This is what we can use as a placeholder for an unknown type, we can now type in a generic abstract way that whatever type we pass in as the arg, we also pass out. This happens consistently.

from typing import TypeVar

T = TypeVar("T")

def record_visit(recordable: T) -> tuple[T, str]:
    timecard = f"Visitor: {recordable.id}, Time: {datetime.utcnow()}"
    ...
    return recordable, timecard

Well, not quite. We’re now stripping all the type information out for the recordable object. The id check now fails with…

mypy main.py
...
main.py:34: error: "T" has no attribute "name"  [attr-defined]

So how can we fix this? Well, in comes Protocols. Protocols are how we define generic interfaces that can be checked accross functions. They allow us to:

  1. Define properties that a class must posses to pass type checking.
  2. Declare an interface without having to use explicit inheritance ask you would when using abc.ABC.
  3. Allow us to using typing and static analysis as well as runtime checking. This is done using the runtime_checkable decorator.

Mixing both generics and protocols together gives us:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Named(Protocol):
    name: str


T = TypeVar("T", bound=Named)


def record_visit(_object: T) -> tuple[T, str]:
    timecard = f"Visitor: {_object.name}, Time: {datetime.utcnow()}"
    ...
    return _object, timecard

We’re now creating a protocol that we’ve called Named. Now we can bind that protocol to our type. Any instance of type T now must implement that protocol. Running our type checker again to check:

mypy main.py
Success: no issues found in 1 source file

That’s everything working, we get attribute checking AND generics support. A full implementation of this is available here as reference. The entire mypy documentation on this subject is well worth a read and is a great concise reference that’s clearer than the relevant PEP.


  1. This is sometimes handy when you’re chaining downstream functions onto the result of this function. This is in functional languages (e.g. Lisps with threading matrocs) and copies over nicely into Python. ↩︎