· · ─ ·✶· ─ · ·
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:
- Nominal subtyping is strictly based on inheritance. A class that inherits from a parent class is a subtype of its parent.
- 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.
- Traditional Duck Typing (Dynamic): Lookups happen at runtime. If the method is missing, the program crashes while running (
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 hereThis 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
- 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.
- 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 protocolsHow 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_checkabledecorator marks a protocol class as a runtime protocol so that you can use it withisinstance()andissubclass()
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)· · ─ ·✶· ─ · ·