Duck Typing

What is duck typing?

Duck typing is a philosophy of handling objects based on their behavior rather than their type.

For example, in Python:

def process(items):
    for x in items:
        ...

items could be a list, generator, pandas array, etc. We do not specify its type strictly, such as items: list | Generator | ....

len(obj)

obj could be a list, string, etc.

Why does duck typing exist?

Duck typing exists because flexibility and extensibility are prioritized over strictness.

With duck typing:

class Dog:
    def speak(self) -> str:
        return "woof"


class Robot:
    def speak(self) -> str:
        return "beep"


def make_it_speak(obj) -> None:
    print(obj.speak())


make_it_speak(Dog())    # woof
make_it_speak(Robot())  # beep

Without duck typing:

class Animal:
    def speak(self) -> str:
        raise NotImplementedError


class Dog(Animal):
    def speak(self) -> str:
        return "woof"


class Robot:
    def speak(self) -> str:
        return "beep"


def make_animal_speak(animal: Animal) -> None:
    if not isinstance(animal, Animal):
        raise TypeError("animal must be an Animal")
    print(animal.speak())

make_animal_speak(Dog())    # works
make_animal_speak(Robot())  # TypeError

Duck typing avoids unnecessary inheritance hierarchies and rigid type systems.

Downsides of duck typing

The downside of duck typing is that it can be too flexible, which can make it harder to enforce consistent rules.

Duck typing is an implicit contract.

def save(obj):
    obj.write()

We need to infer that the object has a write() method from the codebase.

We may not discover a runtime error until we execute the code.

class A:
    pass

save(A())  # AttributeError

In large systems, this style can cause issues.

Duck typing and interfaces in Python

We can use duck typing in Python without explicitly specifying types.

class FileLogger:
    def write(self, message: str) -> None:
        print(f"[file] {message}")


class SocketLogger:
    def write(self, message: str) -> None:
        print(f"[socket] {message}")


def log(writer, message: str) -> None:
    writer.write(message)


log(FileLogger(), "hello")
log(SocketLogger(), "hello")

We can use an ABC to define interfaces explicitly.

from abc import ABC, abstractmethod


class Writer(ABC):

    @abstractmethod
    def write(self, message: str) -> None:
        pass


class FileLogger(Writer):
    def write(self, message: str) -> None:
        print(f"[file] {message}")


class BrokenLogger(Writer):
    pass

BrokenLogger()
# TypeError: Can't instantiate abstract class

We can also use a Protocol. This lets us use the type system while still writing code in a duck-typing style.

from typing import Protocol


class Writer(Protocol):
    def write(self, message: str) -> None:
        ...


class FileLogger:
    def write(self, message: str) -> None:
        print(f"[file] {message}")


class SocketLogger:
    def write(self, message: str) -> None:
        print(f"[socket] {message}")


def log(writer: Writer, message: str) -> None:
    writer.write(message)

Practical guidelines

  • Internal implementation: duck typing
  • Public API: Protocol
  • Complex domain: ABC

This is not a strict rule, and it depends on your team's situation. However, the visibility and complexity of an object are good ways to decide which style is appropriate.