Protocol in Python: Static Duck Typing for Static Type Checking and Better Code Architecture

Python tutorial - IT technology blog
Python tutorial - IT technology blog

When to Use Protocol Instead of ABC or isinstance()?

I use Python as an automation tool for most of my daily tasks, from deploy scripts to monitoring alerts. One problem I frequently encounter is: code is written, tests pass, but when integrating a new component into the system, runtime errors appear — usually because a class is missing a method that the code expects.

Python is inherently a duck typing language: “if it can quack like a duck, it’s a duck.” Python doesn’t care what class an object belongs to — it only needs to have the required method. The problem is that this check only happens at runtime — when the code actually runs, not when it’s being written.

Abstract Base Class (ABC) addresses part of this, but requires explicit inheritance — which isn’t ideal when working with third-party libraries or when you want to keep your code loosely coupled.

Protocol (PEP 544, Python 3.8+) was introduced to solve exactly this problem: static duck typing — checking duck typing at development time with a type checker like mypy, rather than at runtime.

Setting Up the Environment

Protocol is available in the typing module from Python 3.8 onward. If you’re using Python 3.7 or earlier, install typing_extensions:

pip install typing_extensions mypy

Verify your Python and mypy versions:

python --version
# Python 3.8+ is sufficient, Protocol is available in stdlib

pip install mypy
mypy --version

mypy is the official static type checker for Python — use it to catch Protocol errors while you write code.

Defining and Using Protocol — In Depth

1. Basic Protocol

Instead of subclassing ABC and calling register(), you define a Protocol that describes the expected “shape” of an object:

from typing import Protocol

class Serializable(Protocol):
    def to_json(self) -> str:
        ...

    def from_json(self, data: str) -> None:
        ...

Method bodies in Protocol are typically .... This is an interface definition, not an implementation.

2. Using Protocol in Function Signatures

def save_to_file(obj: Serializable, filepath: str) -> None:
    data = obj.to_json()
    with open(filepath, 'w') as f:
        f.write(data)

# This class does NOT inherit from Serializable,
# but has all the required methods — mypy still accepts it
class UserConfig:
    def __init__(self, username: str, theme: str):
        self.username = username
        self.theme = theme

    def to_json(self) -> str:
        import json
        return json.dumps({"username": self.username, "theme": self.theme})

    def from_json(self, data: str) -> None:
        import json
        d = json.loads(data)
        self.username = d["username"]
        self.theme = d["theme"]

config = UserConfig("admin", "dark")
save_to_file(config, "/tmp/config.json")  # Works with mypy!

This is the key insight: UserConfig doesn’t need class UserConfig(Serializable). Having the right methods is all that mypy requires.

3. Protocol with @runtime_checkable

By default, Protocol only works with static type checkers. If you want to use isinstance() at runtime, add the @runtime_checkable decorator:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None:
        ...

class DatabaseConnection:
    def close(self) -> None:
        print("DB connection closed")

db = DatabaseConnection()
print(isinstance(db, Closeable))      # True
print(isinstance("hello", Closeable)) # False

Important note: isinstance() with @runtime_checkable only checks for the existence of methods, not their signatures (parameter types, return types). Full signature checking is mypy’s job.

4. Real-World Example: Plugin System for Alert Handlers

I often use this pattern when building extensible tools — a monitoring alert system is a typical example:

from typing import Protocol
from dataclasses import dataclass

@dataclass
class AlertEvent:
    level: str      # "info", "warning", "critical"
    message: str
    source: str

class AlertHandler(Protocol):
    def handle(self, event: AlertEvent) -> bool:
        """Returns True if handled successfully"""
        ...

    def is_available(self) -> bool:
        ...

# Handler 1: Send via Telegram
class TelegramHandler:
    def __init__(self, bot_token: str, chat_id: str):
        self.bot_token = bot_token
        self.chat_id = chat_id

    def handle(self, event: AlertEvent) -> bool:
        print(f"[Telegram] {event.level}: {event.message}")
        return True

    def is_available(self) -> bool:
        return bool(self.bot_token and self.chat_id)

# Handler 2: Write to log file
class FileLogHandler:
    def __init__(self, log_path: str):
        self.log_path = log_path

    def handle(self, event: AlertEvent) -> bool:
        with open(self.log_path, 'a') as f:
            f.write(f"[{event.level}] {event.source}: {event.message}\n")
        return True

    def is_available(self) -> bool:
        import os
        return os.access(os.path.dirname(self.log_path) or '.', os.W_OK)

# dispatch_alert accepts list[AlertHandler] — no inheritance required
def dispatch_alert(event: AlertEvent, handlers: list[AlertHandler]) -> None:
    for handler in handlers:
        if handler.is_available():
            success = handler.handle(event)
            if not success:
                print(f"{type(handler).__name__} failed")

# Usage
event = AlertEvent(level="critical", message="CPU > 95%", source="server-01")
handlers: list[AlertHandler] = [
    TelegramHandler("TOKEN", "CHAT_ID"),
    FileLogHandler("/var/log/alerts.log"),
]
dispatch_alert(event, handlers)

The benefit is clear: to add a new handler (Slack, PagerDuty…), you just create a class with the right two methods — no need to touch existing code. This follows the Open/Closed Principle.

Type Checking and Monitoring with mypy

Running mypy to Catch Errors Early

# Check the entire project
mypy .

# Strict mode — catches more errors
mypy alert_system.py --strict

Example of mypy errors when passing an object that doesn’t satisfy the Protocol:

class BadHandler:
    def handle(self, event: AlertEvent) -> None:  # Wrong! Protocol requires -> bool
        pass
    # Missing is_available()

# mypy reports:
# error: Argument 2 to "dispatch_alert" has incompatible type
#        "list[BadHandler]"; expected "list[AlertHandler]"

Configuring mypy with pyproject.toml

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

Integrating into CI/CD

# In GitHub Actions
pip install mypy
mypy . --exit-code  # non-zero exit code if there are type errors

# Combined with pytest
pytest tests/ && mypy .

I usually add mypy to a pre-commit hook to catch errors before committing:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

When to Use Protocol vs. ABC?

  • Protocol: When you want loose coupling, don’t want to enforce inheritance, or work with third-party code
  • ABC: When you want strong coupling, need @abstractmethod to enforce implementation, or want to share code via super()
  • TypedDict: When working with fixed-structure dicts, not class instances

For me, Protocol is the default choice when designing interfaces for plugin systems or components that need clear boundaries. ABC is only used when I want to share implementation through inheritance.

Share: