Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 73

Duck Typing with Protocols

Recommended resources

Introduction

In our last lesson, you mastered the fundamentals of annotating Python code using basic types like list, dict, Optional, Union, and Callable. You now have the tools to make function signatures explicit, which improves readability and enables static analysis.

This lesson addresses a core concept that directly bridges your Java background with Python's unique philosophy: defining interfaces. In Java, you use the interface keyword to enforce a contract through explicit implementation. Python, which favors duck typing ("if it walks like a duck and quacks like a duck, then it must be a duck"), takes a different approach.

Today, you will learn how to use typing.Protocol to define interfaces for duck-typed objects without requiring inheritance. A Protocol formalizes this "duck test" for static type checkers like mypy. It allows you to specify what an object can do (its behavior and attributes) rather than what it is (its class hierarchy). This is a powerful way to write flexible, decoupled code that remains type-safe.


Structural vs. Nominal Typing: The Core Idea

The distinction between Protocol and a Java interface or a Python Abstract Base Class (ABC) comes down to two different ways of thinking about types:

  1. Nominal Typing: Type compatibility is determined by explicit declaration. If class B inherits from class A, then B is a subtype of A. This is the world of class MyClass(MyParent): and Java's class MyClass implements MyInterface. The name and declared hierarchy matter.

  2. Structural Typing: Type compatibility is determined by structure. If class B has the same methods and attributes as defined by an "interface" A, then it's compatible, regardless of whether it explicitly inherits from A. This is the formal name for duck typing. The structure and behavior matter.

typing.Protocol is Python's tool for structural typing.

Nominal vs. Structural Typing with Python Protocols
This diagram illustrates the difference. On the left, **Nominal** typing requires an explicit inheritance relationship. On the right, **Structural** typing, as implemented by `Protocol`, only requires that an object has the necessary methods and attributes to be considered compatible.

To solidify this foundational concept, the following article provides an excellent explanation.

Python Protocols: Leveraging Structural Subtyping

The Real Python article 'Python Protocols' provides a clear breakdown of structural vs. nominal subtyping. Understanding this is key to grasping the power of Protocol.

Read the introduction and the section titled 'Structural Subtyping and Protocols'. Focus on how the Dog and Cat classes can be used interchangeably in animals_v2.py because they share a common structure, even without a common parent class.


Defining and Using a Protocol

You define a protocol by inheriting from typing.Protocol. Inside the protocol class, you define the methods and attributes that any conforming object must have. Method bodies in a protocol should be empty, typically indicated with ....

Let's look at a classic example. Suppose we want to work with any object that has an area.

from typing import Protocol
import math




# 1. Define the Protocol
class HasArea(Protocol):
    """A protocol for objects that have an area."""
    def area(self) -> float:
        ...




# 2. Define conforming classes (NO INHERITANCE from HasArea)
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2




# 3. Use the Protocol in a function signature
def print_area(shape: HasArea):
    """Prints the area of any object that conforms to the HasArea protocol."""



    # Mypy knows that `shape` has an `area()` method because of the type hint.
    print(f"The area is: {shape.area():.2f}")




# 4. Use with conforming objects
rect = Rectangle(10, 5)
circ = Circle(7)

print_area(rect)  # Mypy passes this: Rectangle has an area() method.
print_area(circ)  # Mypy passes this: Circle has an area() method.




# What if we pass a non-conforming object?
class Triangle:
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height



    # No area() method!
    def calculate_area(self) -> float:
        return 0.5 * self.base * self.height

tri = Triangle(10, 5)



# print_area(tri) 
# ^-- If you uncomment this and run mypy, it will raise an error:
# error: Argument 1 to "print_area" has incompatible type "Triangle"; expected "HasArea"
# note: "Triangle" is missing "area"

Notice the key difference from Java: Rectangle and Circle don't mention HasArea anywhere. They conform implicitly because they have the right structure. This is extremely powerful for working with code from third-party libraries where you cannot modify the class definitions to add a base class.


Practical Example: Decoupling Code with Protocols

One of the most significant benefits of protocols is decoupling your application's logic from concrete implementations. This makes your code more flexible, extensible, and easier to test.

The following video demonstrates a classic "bad vs. good" refactoring. It starts with a function tightly coupled to a specific email service and refactors it to use a protocol, freeing it from any single implementation.

Avoid These BAD Practices in Python OOP

ArjanCodes provides a fantastic, practical example of why you should 'rely on abstractions'. This segment shows how to use a Protocol to define an abstraction and decouple a function from a concrete class.

Watch from 07:48 to 11:24. Pay close attention to the following steps: The initial 'bad' code where process_order directly creates an SMTPEmailService instance. The first improvement: injecting the dependency (dependency inversion). The final, most flexible solution: defining an EmailService protocol and using it as the type hint for the function argument. This allows any object that 'quacks like an email service' to be used.

This pattern is fundamental to writing clean, maintainable systems. By programming to an interface (the Protocol) rather than an implementation, you can easily swap out the SMTPEmailService for a SendGridEmailService or a MockEmailService for testing, without changing the process_order function at all.


Protocol vs. Abstract Base Classes (ABC)

You might be wondering how Protocol differs from Python's abc.ABC. Both are used to define interfaces, but they operate on the principles we discussed earlier:

  • abc.ABC: Enforces nominal subtyping. A class must explicitly inherit from the ABC to be considered a subtype. If you don't implement all abstract methods, Python raises a TypeError at instantiation time.
  • typing.Protocol: Enforces structural subtyping. A class conforms if it has the right structure. This is checked by a static type checker like mypy, not at runtime (by default).

Watch this video for a direct comparison of the two.

Protocols vs ABCs in Python - When to Use Which One?

Let's watch another video from ArjanCodes that directly compares Protocols and ABCs and discusses when to use each.

Watch the segments from 01:04 to 01:51 for the core distinction and 13:53 to 14:57 for the discussion on use cases. The key takeaway is: use ABCs when you control the hierarchy and want a clear, explicit inheritance relationship; use Protocols when you need to define an interface for classes you don't control or want to avoid the rigidity of inheritance.

In summary:

Feature abc.ABC (Nominal) typing.Protocol (Structural)
Relationship Explicit inheritance (class MyImpl(MyABC):) Implicit conformance (matching structure)
Enforcement Runtime (TypeError on instantiation) Static time (mypy error)
Flexibility Less flexible; requires modifying classes More flexible; works with third-party code
Best Use Case Defining a framework where you control all implementing classes. Defining an interface for existing code or for maximum decoupling.

Runtime Checking: The @runtime_checkable Decorator

By default, isinstance() checks fail with a protocol because they are designed for static analysis.

from typing import Protocol

class Greeter(Protocol):
    def greet(self) -> str: ...

class Person:
    def greet(self) -> str:
        return "Hello"

p = Person()



# isinstance(p, Greeter) # -> TypeError: Instance and class checks can only be used with @runtime_checkable protocols

To enable runtime isinstance() checks, you can use the @runtime_checkable decorator from typing.

from typing import Protocol, runtime_checkable

@runtime_checkable
class Greeter(Protocol):
    def greet(self) -> str: ...

class Person:
    def greet(self) -> str:
        return "Hello"

p = Person()
print(isinstance(p, Greeter)) # -> True

However, be aware of two points:

  1. Performance: Runtime checking adds overhead, as Python must inspect the object's attributes and methods.
  2. Incompleteness: It only checks for the presence of the required methods/attributes, not their function signatures.

The ArjanCodes video you watched earlier (LINK) demonstrates this limitation clearly between 11:18 and 12:27. An object can pass an isinstance check but still cause a TypeError at runtime if its method signature doesn't match what's expected. For this reason, rely primarily on static checking with mypy for correctness and use @runtime_checkable sparingly when runtime introspection is truly necessary.


Conclusion

In this lesson, you've unlocked one of the most powerful and Pythonic features of the typing system. You now understand how typing.Protocol enables "static duck typing," providing the safety of interfaces without the rigidity of inheritance. For someone coming from a Java background, this is a crucial mental shift from nominal to structural typing.

Key Takeaways:

  • Structural vs. Nominal Typing: Protocol is for structural typing (what an object does), while ABCs and Java interfaces are for nominal typing (what an object is).
  • Implicit Conformance: Classes do not need to inherit from a Protocol to conform to it. They only need to have the correct structure (methods and attributes).
  • Decoupling: Protocols are a primary tool for decoupling application logic from concrete implementations, leading to more flexible and testable code.
  • Static Enforcement: The main benefit of Protocol is realized through static type checkers like mypy.
  • @runtime_checkable: Use this decorator only when you explicitly need runtime isinstance() checks, and be aware of its limitations.

In our next lesson, we will look at another specialized typing tool: TypedDict. You'll learn how to use it to precisely define the structure of dictionaries with fixed string keys, bringing type safety to a data structure that is often a source of runtime errors.