· · ─ ·✶· ─ · ·

Ok, Im just gonna get into this. No flair, nothing. I didnt even know this existed in Python. Well, maybe that’s because I didnt know about the structural subtyping concept (more on that later).

Let’s go in order

What is duck typing?

Duck typing is a typing system typically associated with dynamically typed languages (such as Python or Javascript), where if an object “acts like a duck, quacks like a duck, then it is a duck”. No questions asked.

See this for some more on this topic 2025-06-20-Duck-Typing-EAFP-And-LBYL-Programming-Styles

What are type hints in Python?

See, dynamically typed is well and all but people really wanted the type safety that comes from static typing. So Python gave in and finagled in “type hints”. This doesn’t make Python statically typed. No sir. It just allows external static type checkers like ruff or mypy to be able to check type violations.

It looks like this:

def my_func(a: int) -> int:
    ...
What are Protocols in Python?

In Python, a protocol specifies the methods and attributes that a class must implement to be considered of a given type

In Python’s type system, you’ll find two ways to decide whether two objects are compatible as types:

  1. Nominal subtyping is strictly based on inheritance. A class that inherits from a parent class is a subtype of its parent.
  2. Structural subtyping is based on the internal structure of classes. Two classes with the same methods and attributes are structural subtypes of one another.

So Protocols allow for stuctural subtyping or static duck typing.

  • static duck typing is possible in statically typed languages but just duck typing is just possible in dynamically typed languages
    • When people say pure, traditional “duck typing,” they are referring to the runtime philosophy: “If it walks like a duck and quacks like a duck, it’s a duck.” This inherently requires a dynamically typed environment because the language doesn’t care about types beforehand; it just blindly attempts to call the method at runtime and throws an error if it doesn’t exist.
    • What languages like Go, C++, or Python (with Protocols) do is technically structural typing (or static duck typing). It mimics the feeling of duck typing, but it shifts the check entirely to the compilation or static analysis phase.
    • To make the distinction crystal clear:
      • Traditional Duck Typing (Dynamic): Lookups happen at runtime. If the method is missing, the program crashes while running (AttributeError).
      • Static Duck Typing / Structural Typing: Lookups happen at compile-time / analysis-time. If the method is missing, the code fails to compile or the type checker flags an error before execution.
What’s the reason we need Protocols/Structural Subtyping?

Lets say we have the below code

class Duck:
    def quack(self):
        return "The duck is quacking!"
 
def make_it_quack(duck: Duck) -> str:
    return duck.quack()
 
class Person:
    def quack(self):
        return "The person is imitating a duck quacking!"
 
print(make_it_quack(Duck()))
 
print(make_it_quack(Person())) # static type checker fails here

This works because of dynamic typing/duck typing. But a static type checker will fail.

How to fix? Inheritance.

class QuackingThing:
    def quack(self):
        raise NotImplementedError(
            "Subclasses must implement this method"
        )
 
class Duck(QuackingThing):
    def quack(self):
        return "The duck is quacking!"
 
class Person(QuackingThing):
    def quack(self):
        return "The person is imitating a duck quacking!"
 
def make_it_quack(duck: QuackingThing) -> str:
    return duck.quack()
 
print(make_it_quack(Duck()))
 
print(make_it_quack(Person()))

Now it works and a static type checker will also not fail. But now even though it looks like static duck typing, its not. This is nominal subtyping via inheritance. The classes are now coupled and we have lost the main draw of duck typing ie, decoupled classes.

Duck typing and type hints collide in Python (as we saw above). If programmers want to use both, then we can do so via protocols/structural subtyping.

Example usage of Protocols
from typing import Protocol
 
class Adder(Protocol):
    def add(self, x, y): ...
 
class IntAdder:
    def add(self, x, y):
        return x + y
 
class FloatAdder:
    def add(self, x, y):
        return x + y
 
def add(adder: Adder) -> None:
    print(adder.add(2, 3))
 
add(IntAdder())
add(FloatAdder())

Author’s note: This is damn cool.

Generic protocols in Python
from typing import Protocol, TypeVar
 
T = TypeVar("T", bound = int | float)
 
class MyProtocol(Protocol[T]):
	def add(self, x : T, y : T) -> T: ...
	
class IntAdder:
	def add(self, x: int, y: int) -> int:
		return x+y
		
class FloatAdder:
	def add(self, x: float, y: float) -> float:
		return x+y
		
def add(adder: MyProtocol) -> None:
	adder.add(2,3)
 
add(IntAdder())
add(FloatAdder())
  • Whatever nominal subtyping/inheriance helps us achieve ie, create two or more classes/types which can be used in a duck typing context If we dont want to use inheritance, we can achieve the same thing via Protocols/Structural subtyping.

Note:

In structural subtyping (often referred to as static duck typing), a class is considered a valid subtype of a protocol as long as it satisfies the minimum requirements of that protocol. Having extra attributes or methods does not break this relationship

Issues with using Protocols
  1. Protocols can make a completely unrelated type be accepted by the type checker, purely by accident.
from typing import Protocol
 
class Message(Protocol):
    def encode(self) -> bytes:
        ...
 
def send(message: Message) -> None:
    ...
 
send("Hello, World!")  # Passes the type checker

"Hello, World!" happens to have a .encode() which returns bytes. So it gets accepted by the type checker. But we may want only custom classes to be accepted here.

  1. Another potential downside of protocols is that isinstance() will raise an exception when used with them.
>>> isinstance(IntAdder(), MyAdder)
Traceback (most recent call last):
    ...
TypeError: Instance and class checks can only be used with
    @runtime_checkable protocols

How can we fix this?

from typing import Protocol, runtime_checkable
 
@runtime_checkable
class MyProtocol(Protocol[T]):
	def add(self, x : T, y : T) -> T: ...
  • The @runtime_checkable decorator marks a protocol class as a runtime protocol so that you can use it with isinstance() and issubclass()
A full example of Protocols with methods and attributes
from typing import Protocol, runtime_checkable
 
# 1. Define the Protocol
@runtime_checkable  # Allows us to use isinstance() at runtime
class Vehicle(Protocol):
    # Writable instance attributes (must be annotated in the class body)
    make: str
    model: str
    mileage: int
    
    # Read-only attribute (using property)
    @property
    def fuel_type(self) -> str:
        ...
 
    # A standard protocol method
    def drive(self, distance: int) -> None:
        ...
 
 
# 2. Implement the Protocol in a concrete class
# Note: Python's Protocol is structural (duck-typed); you don't need to inherit from Vehicle!
class Car:
    def __init__(self, make: str, model: str, initial_mileage: int, fuel: str) -> None:
        # Implementing the writable attributes
        self.make = make
        self.model = model
        self.mileage = initial_mileage
        self._fuel = fuel
 
    # Implementing the read-only attribute
    @property
    def fuel_type(self) -> str:
        return self._fuel
 
    # Implementing the protocol method
    def drive(self, distance: int) -> None:
        self.mileage += distance
        print(f"Drove the {self.make} {self.model} for {distance} miles.")
 
 
# 3. Create a function that expects the Protocol type
def service_vehicle(vehicle: Vehicle) -> None:
    # A type checker (like mypy or Pyright) ensures these attributes exist
    print(f"Servicing: {vehicle.make} {vehicle.model}")
    print(f"Current Mileage: {vehicle.mileage}")
    print(f"Fuel System: {vehicle.fuel_type}")
    
    # Modifying a writable attribute
    vehicle.mileage += 100 
    vehicle.drive(5)
 
 
# 4. Execute the code
if __name__ == "__main__":
    # Create an instance of our Car
    my_car = Car(make="Tesla", model="Model 3", initial_mileage=15000, fuel="Electric")
    
    # Runtime check (enabled by @runtime_checkable)
    print(f"Does Car implement Vehicle? {isinstance(my_car, Vehicle)}")  # Prints: True
    
    # Pass it to the function
    service_vehicle(my_car)

· · ─ ·✶· ─ · ·