Protocols and Structural Typing
18 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Python programs rely on duck typing: if it has a pay method, it can pay. Protocols let you write that rule in a type hint, so a checker can confirm it before the program runs.
Duck typing needs a name
Protocol is a class that lists the methods and attributes an object must have. Any class that has them matches the protocol, with no inheritance needed.
Python code often accepts "anything that can do this", such as anything with a pay method. A protocol writes that rule down so a checker can verify it.
from typing import Protocol
class Payable(Protocol): def pay(self, amount: float) -> str: ...The ... body means "no code here". A protocol lists methods and attributes. It is never used to build objects.
Matching by shape
Structural typing means a type matches by its shape, the methods and attributes it has, not by its parent classes. It is duck typing that a checker can verify.
A class matches a protocol just by having the right methods. It never names the protocol or inherits from it.
class Card: def pay(self, amount: float) -> str: return f"Card paid {amount}"
def checkout(method: Payable, amount: float) -> str: return method.pay(amount)Example 1 · Two classes, one protocol
from typing import Protocol
class Payable(Protocol): def pay(self, amount: float) -> str: ...
class Card: def pay(self, amount: float) -> str: return f"Card paid {amount}"
class Wallet: def pay(self, amount: float) -> str: return f"Wallet paid {amount}"
def checkout(method: Payable, amount: float) -> str: return method.pay(amount)
for method in (Card(), Wallet()): print(checkout(method, 50.0))Card paid 50.0 Wallet paid 50.0
What this code does
CardandWalletshare no parent, yet both fitPayable.- Adding a new payment class later needs no change to
checkout.
Example 2 · mypy rejects a class with the wrong shape
Runs in: Your terminal
mypy checkout.pycheckout.py:23: error: Argument 1 to "checkout" has incompatible type "Cash"; expected "Payable" [arg-type] Found 1 error in 1 file (checked 1 source file)
What this code does
Cashhascollect, notpay, so mypy rejects the second call.- Running the file would also fail, but only when that line is reached. mypy finds it first.
Card never mentions Payable, yet checkout(Card(), 50.0) passes the check. A class with no pay method would be rejected.
Attributes in a protocol
A protocol can also list attributes. Any object that has an attribute with that name and type matches.
class HasName(Protocol): name: str
def greet(thing: HasName) -> str: return f"Hello, {thing.name}"Example 3 · A protocol for an attribute
from typing import Protocol
class HasName(Protocol): name: str
class Student: def __init__(self, name: str) -> None: self.name = name
class Course: def __init__(self, name: str, fee: int) -> None: self.name = name self.fee = fee
def greet(thing: HasName) -> str: return f"Hello, {thing.name}"
print(greet(Student("Asha")))print(greet(Course("Python", 4000)))Hello, Asha Hello, Python
What this code does
greetneeds only aname. It does not care aboutfee.- Both classes match, though they are unrelated.
A student, a course and a customer can all be passed to greet, as long as each one has a name string. This keeps the function from depending on any one class.
Protocol or abstract base class
An abstract base class forces a class to inherit and to fill in every method. A protocol asks for no inheritance and is checked only by tools such as mypy.
from abc import ABC, abstractmethod
class PayableBase(ABC): @abstractmethod def pay(self, amount: float) -> str: ...
class Broken(PayableBase): passExample 4 · An abstract class fails at runtime
from abc import ABC, abstractmethod
class PayableBase(ABC): @abstractmethod def pay(self, amount: float) -> str: ...
class Broken(PayableBase): pass
try: Broken()except TypeError as error: print(error)Can't instantiate abstract class Broken without an implementation for abstract method 'pay'
What this code does
- The abstract base class stops the object from being built at all.
- A protocol gives no such runtime error. Its check happens in mypy.
Creating Broken() fails at runtime, because pay is missing. Use a protocol for code you do not own, or when a shared parent is not wanted. Use an abstract base class when you also want shared code and a runtime error.
Checking a protocol at runtime
runtime_checkable is a decorator that lets isinstance and issubclass test an object against a protocol. It checks only that the named methods exist.
A plain protocol cannot be used with isinstance. Add @runtime_checkable when you need that. It only checks that the names exist, not their signatures.
import iofrom typing import Protocol, runtime_checkable
@runtime_checkableclass Closeable(Protocol): def close(self) -> None: ...
print(isinstance(io.StringIO(), Closeable))Example 5 · runtime_checkable
import iofrom typing import Protocol, runtime_checkable
@runtime_checkableclass Closeable(Protocol): def close(self) -> None: ...
print(isinstance(io.StringIO(), Closeable))print(isinstance(42, Closeable))True False
What this code does
- A
StringIOhas aclosemethod, so it matches. - An
intdoes not. Only the method name is tested, not its arguments or return type.
Use this sparingly. The strength of a protocol is the static check before the program runs.
Next step
Check your understanding
11 quick questions