Python Protocols and Interfaces: A Comprehensive Guide
Overview
Python’s typing.Protocol provides a way to define structural interfaces without explicit inheritance. Protocols enable duck typing with static type checking, allowing you to define interfaces based on behavior rather than inheritance hierarchies.
Table of Contents
- Introduction to Protocols
- Basic Protocol Usage
- Advanced Protocol Features
- Built-in Protocols
- Protocol vs Abstract Base Classes
- Real-World Examples
- Best Practices
- Common Interview Questions
- Performance Considerations
- Type Checking Integration
Introduction to Protocols
What are Protocols?
Protocols define structural interfaces - they specify what methods and attributes an object must have to be considered compatible with the protocol, without requiring explicit inheritance.
Key Concepts
- Structural Typing: Based on structure (methods/attributes) rather than inheritance
- Duck Typing: “If it walks like a duck and quacks like a duck, it’s a duck”
- Static Type Checking: Provides type safety without runtime overhead
- Composition over Inheritance: Encourages composition-based design
Basic Syntax
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def get_position(self) -> tuple[int, int]: ...
Basic Protocol Usage
Simple Protocol Definition
from typing import Protocol
class Printable(Protocol):
def print_info(self) -> str: ...
class User:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def print_info(self) -> str:
return f"User: {self.name}, Age: {self.age}"
class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price
def print_info(self) -> str:
return f"Product: {self.name}, Price: ${self.price}"
def display_info(item: Printable) -> None:
print(item.print_info())
# Both User and Product work with display_info
user = User("Alice", 30)
product = Product("Laptop", 999.99)
display_info(user) # Output: User: Alice, Age: 30
display_info(product) # Output: Product: Laptop, Price: $999.99
Protocol with Properties
from typing import Protocol
class Measurable(Protocol):
@property
def area(self) -> float: ...
@property
def perimeter(self) -> float: ...
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
return self.width * self.height
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
class Circle:
def __init__(self, radius: float):
self.radius = radius
@property
def area(self) -> float:
import math
return math.pi * self.radius ** 2
@property
def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius
def print_measurements(shape: Measurable) -> None:
print(f"Area: {shape.area:.2f}")
print(f"Perimeter: {shape.perimeter:.2f}")
# Both shapes work with the function
rectangle = Rectangle(5, 3)
circle = Circle(4)
print_measurements(rectangle)
print_measurements(circle)
Protocol with Generic Types
from typing import Protocol, TypeVar, Generic
T = TypeVar('T')
class Comparable(Protocol[T]):
def __lt__(self, other: T) -> bool: ...
def __eq__(self, other: object) -> bool: ...
class Sortable(Protocol[T]):
def sort(self, items: list[T]) -> list[T]: ...
class NumberSorter:
def sort(self, items: list[int]) -> list[int]:
return sorted(items)
class StringSorter:
def sort(self, items: list[str]) -> list[str]:
return sorted(items)
def sort_data(sorter: Sortable[T], data: list[T]) -> list[T]:
return sorter.sort(data)
# Usage
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
strings = ["banana", "apple", "cherry"]
number_sorter = NumberSorter()
string_sorter = StringSorter()
sorted_numbers = sort_data(number_sorter, numbers)
sorted_strings = sort_data(string_sorter, strings)
Advanced Protocol Features
Protocol with Optional Methods
from typing import Protocol, Optional
class Cacheable(Protocol):
def get(self, key: str) -> Optional[str]: ...
def set(self, key: str, value: str) -> None: ...
def delete(self, key: str) -> None: ...
class SimpleCache:
def __init__(self):
self._cache: dict[str, str] = {}
def get(self, key: str) -> Optional[str]:
return self._cache.get(key)
def set(self, key: str, value: str) -> None:
self._cache[key] = value
def delete(self, key: str) -> None:
self._cache.pop(key, None)
def clear(self) -> None: # Extra method not in protocol
self._cache.clear()
class RedisCache:
def __init__(self, connection_string: str):
self.connection_string = connection_string
def get(self, key: str) -> Optional[str]:
# Simulate Redis get
return f"redis_value_for_{key}"
def set(self, key: str, value: str) -> None:
# Simulate Redis set
print(f"Setting {key}={value} in Redis")
def delete(self, key: str) -> None:
# Simulate Redis delete
print(f"Deleting {key} from Redis")
def cache_operation(cache: Cacheable, key: str, value: str) -> None:
cache.set(key, value)
retrieved = cache.get(key)
print(f"Retrieved: {retrieved}")
# Both cache implementations work
simple_cache = SimpleCache()
redis_cache = RedisCache("redis://localhost:6379")
cache_operation(simple_cache, "test", "value")
cache_operation(redis_cache, "test", "value")
Protocol with Callable Methods
from typing import Protocol, Callable, Any
class EventHandler(Protocol):
def handle(self, event: str, data: Any) -> None: ...
def can_handle(self, event: str) -> bool: ...
class LoggingHandler:
def handle(self, event: str, data: Any) -> None:
print(f"Logging event: {event} with data: {data}")
def can_handle(self, event: str) -> bool:
return event.startswith("log.")
class EmailHandler:
def handle(self, event: str, data: Any) -> None:
print(f"Sending email for event: {event}")
def can_handle(self, event: str) -> bool:
return event.startswith("email.")
class EventDispatcher:
def __init__(self):
self.handlers: list[EventHandler] = []
def add_handler(self, handler: EventHandler) -> None:
self.handlers.append(handler)
def dispatch(self, event: str, data: Any) -> None:
for handler in self.handlers:
if handler.can_handle(event):
handler.handle(event, data)
# Usage
dispatcher = EventDispatcher()
dispatcher.add_handler(LoggingHandler())
dispatcher.add_handler(EmailHandler())
dispatcher.dispatch("log.error", "Something went wrong")
dispatcher.dispatch("email.notification", "New message received")
Protocol with Context Managers
from typing import Protocol
class DatabaseConnection(Protocol):
def connect(self) -> None: ...
def disconnect(self) -> None: ...
def execute(self, query: str) -> list[dict]: ...
def __enter__(self) -> 'DatabaseConnection': ...
def __exit__(self, exc_type, exc_val, exc_tb) -> None: ...
class SQLiteConnection:
def __init__(self, database_path: str):
self.database_path = database_path
self.connected = False
def connect(self) -> None:
self.connected = True
print(f"Connected to SQLite database: {self.database_path}")
def disconnect(self) -> None:
self.connected = False
print("Disconnected from SQLite database")
def execute(self, query: str) -> list[dict]:
if not self.connected:
raise RuntimeError("Not connected to database")
print(f"Executing query: {query}")
return [{"result": "data"}]
def __enter__(self) -> 'SQLiteConnection':
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.disconnect()
class PostgreSQLConnection:
def __init__(self, connection_string: str):
self.connection_string = connection_string
self.connected = False
def connect(self) -> None:
self.connected = True
print(f"Connected to PostgreSQL: {self.connection_string}")
def disconnect(self) -> None:
self.connected = False
print("Disconnected from PostgreSQL")
def execute(self, query: str) -> list[dict]:
if not self.connected:
raise RuntimeError("Not connected to database")
print(f"Executing PostgreSQL query: {query}")
return [{"result": "postgres_data"}]
def __enter__(self) -> 'PostgreSQLConnection':
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.disconnect()
def run_query(db: DatabaseConnection, query: str) -> list[dict]:
with db:
return db.execute(query)
# Both database types work
sqlite_db = SQLiteConnection("test.db")
postgres_db = PostgreSQLConnection("postgresql://localhost/test")
results1 = run_query(sqlite_db, "SELECT * FROM users")
results2 = run_query(postgres_db, "SELECT * FROM users")
Built-in Protocols
Iterator Protocol
from typing import Protocol, Iterator, TypeVar
T = TypeVar('T')
class Iterable(Protocol[T]):
def __iter__(self) -> Iterator[T]: ...
class Iterator(Protocol[T]):
def __next__(self) -> T: ...
def __iter__(self) -> Iterator[T]: ...
class CustomRange:
def __init__(self, start: int, end: int):
self.start = start
self.end = end
self.current = start
def __iter__(self) -> Iterator[int]:
return self
def __next__(self) -> int:
if self.current >= self.end:
raise StopIteration
result = self.current
self.current += 1
return result
def process_items(items: Iterable[int]) -> None:
for item in items:
print(f"Processing: {item}")
# CustomRange works with any function expecting an Iterable
custom_range = CustomRange(1, 5)
process_items(custom_range)
process_items([1, 2, 3, 4, 5])
Context Manager Protocol
from typing import Protocol
class ContextManager(Protocol):
def __enter__(self) -> object: ...
def __exit__(self, exc_type, exc_val, exc_tb) -> bool: ...
class FileManager:
def __init__(self, filename: str):
self.filename = filename
self.file = None
def __enter__(self) -> 'FileManager':
self.file = open(self.filename, 'r')
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
if self.file:
self.file.close()
return False # Don't suppress exceptions
def read(self) -> str:
return self.file.read() if self.file else ""
def process_file(file_manager: ContextManager) -> None:
with file_manager as fm:
if hasattr(fm, 'read'):
content = fm.read()
print(f"File content: {content}")
# FileManager works with any function expecting a ContextManager
file_mgr = FileManager("example.txt")
process_file(file_mgr)
Callable Protocol
from typing import Protocol, Callable, TypeVar, Any
T = TypeVar('T')
R = TypeVar('R')
class Mapper(Protocol[T, R]):
def __call__(self, item: T) -> R: ...
class StringProcessor:
def __call__(self, text: str) -> str:
return text.upper().strip()
class NumberProcessor:
def __call__(self, number: int) -> float:
return number * 1.5
def apply_mapper(mapper: Mapper[T, R], items: list[T]) -> list[R]:
return [mapper(item) for item in items]
# Both processors work as mappers
string_processor = StringProcessor()
number_processor = NumberProcessor()
texts = [" hello ", " world "]
numbers = [1, 2, 3, 4, 5]
processed_texts = apply_mapper(string_processor, texts)
processed_numbers = apply_mapper(number_processor, numbers)
print(processed_texts) # ['HELLO', 'WORLD']
print(processed_numbers) # [1.5, 3.0, 4.5, 6.0, 7.5]
Protocol vs Abstract Base Classes
Comparison Table
| Feature | Protocol | Abstract Base Class |
|---|---|---|
| Inheritance | Not required | Required |
| Type Checking | Static only | Runtime + Static |
| Performance | No runtime overhead | Runtime overhead |
| Flexibility | High (structural) | Lower (nominal) |
| Explicit Declaration | Not needed | Required |
| Method Implementation | Optional | Required |
Example Comparison
from typing import Protocol
from abc import ABC, abstractmethod
# Protocol approach
class Drawable(Protocol):
def draw(self) -> None: ...
def get_area(self) -> float: ...
class Circle:
def __init__(self, radius: float):
self.radius = radius
def draw(self) -> None:
print(f"Drawing circle with radius {self.radius}")
def get_area(self) -> float:
import math
return math.pi * self.radius ** 2
# ABC approach
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> None:
pass
@abstractmethod
def get_area(self) -> float:
pass
class Rectangle(DrawableABC):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def draw(self) -> None:
print(f"Drawing rectangle {self.width}x{self.height}")
def get_area(self) -> float:
return self.width * self.height
# Both work with the same function
def render_shape(shape: Drawable) -> None:
shape.draw()
print(f"Area: {shape.get_area()}")
# Protocol: No inheritance needed
circle = Circle(5)
render_shape(circle)
# ABC: Inheritance required
rectangle = Rectangle(4, 6)
render_shape(rectangle)
Real-World Examples
Plugin System
from typing import Protocol, Dict, Any
import json
class Plugin(Protocol):
def initialize(self, config: Dict[str, Any]) -> None: ...
def process(self, data: Any) -> Any: ...
def cleanup(self) -> None: ...
class DataProcessorPlugin:
def __init__(self):
self.config = {}
def initialize(self, config: Dict[str, Any]) -> None:
self.config = config
print(f"DataProcessor initialized with config: {config}")
def process(self, data: Any) -> Any:
if isinstance(data, str):
return data.upper()
return data
def cleanup(self) -> None:
print("DataProcessor cleanup completed")
class JsonFormatterPlugin:
def __init__(self):
self.config = {}
def initialize(self, config: Dict[str, Any]) -> None:
self.config = config
print(f"JsonFormatter initialized with config: {config}")
def process(self, data: Any) -> Any:
return json.dumps(data, indent=2)
def cleanup(self) -> None:
print("JsonFormatter cleanup completed")
class PluginManager:
def __init__(self):
self.plugins: list[Plugin] = []
def register_plugin(self, plugin: Plugin) -> None:
self.plugins.append(plugin)
def run_pipeline(self, data: Any) -> Any:
result = data
for plugin in self.plugins:
plugin.initialize({"mode": "production"})
result = plugin.process(result)
plugin.cleanup()
return result
# Usage
manager = PluginManager()
manager.register_plugin(DataProcessorPlugin())
manager.register_plugin(JsonFormatterPlugin())
input_data = {"name": "john", "age": 30}
output = manager.run_pipeline(input_data)
print(output)
Database ORM Interface
from typing import Protocol, TypeVar, Generic, List, Optional, Dict, Any
from datetime import datetime
T = TypeVar('T')
class Model(Protocol):
id: int
created_at: datetime
updated_at: datetime
class Repository(Protocol[T]):
def create(self, data: Dict[str, Any]) -> T: ...
def get_by_id(self, id: int) -> Optional[T]: ...
def update(self, id: int, data: Dict[str, Any]) -> Optional[T]: ...
def delete(self, id: int) -> bool: ...
def list_all(self) -> List[T]: ...
class User:
def __init__(self, id: int, name: str, email: str):
self.id = id
self.name = name
self.email = email
self.created_at = datetime.now()
self.updated_at = datetime.now()
class UserRepository:
def __init__(self):
self.users: Dict[int, User] = {}
self.next_id = 1
def create(self, data: Dict[str, Any]) -> User:
user = User(
id=self.next_id,
name=data['name'],
email=data['email']
)
self.users[user.id] = user
self.next_id += 1
return user
def get_by_id(self, id: int) -> Optional[User]:
return self.users.get(id)
def update(self, id: int, data: Dict[str, Any]) -> Optional[User]:
if id not in self.users:
return None
user = self.users[id]
user.name = data.get('name', user.name)
user.email = data.get('email', user.email)
user.updated_at = datetime.now()
return user
def delete(self, id: int) -> bool:
if id in self.users:
del self.users[id]
return True
return False
def list_all(self) -> List[User]:
return list(self.users.values())
class UserService:
def __init__(self, repository: Repository[User]):
self.repository = repository
def create_user(self, name: str, email: str) -> User:
return self.repository.create({'name': name, 'email': email})
def get_user(self, id: int) -> Optional[User]:
return self.repository.get_by_id(id)
def update_user(self, id: int, name: str = None, email: str = None) -> Optional[User]:
data = {}
if name:
data['name'] = name
if email:
data['email'] = email
return self.repository.update(id, data)
def delete_user(self, id: int) -> bool:
return self.repository.delete(id)
def list_users(self) -> List[User]:
return self.repository.list_all()
# Usage
user_repo = UserRepository()
user_service = UserService(user_repo)
# Create users
user1 = user_service.create_user("Alice", "alice@example.com")
user2 = user_service.create_user("Bob", "bob@example.com")
# List all users
users = user_service.list_users()
for user in users:
print(f"User: {user.name} ({user.email})")
# Update user
updated_user = user_service.update_user(user1.id, name="Alice Smith")
print(f"Updated: {updated_user.name}")
# Delete user
success = user_service.delete_user(user2.id)
print(f"Delete successful: {success}")
Best Practices
1. Keep Protocols Simple
# Good: Simple, focused protocol
class Readable(Protocol):
def read(self) -> str: ...
# Bad: Too many methods in one protocol
class FileHandler(Protocol):
def read(self) -> str: ...
def write(self, data: str) -> None: ...
def delete(self) -> None: ...
def copy(self, destination: str) -> None: ...
def move(self, destination: str) -> None: ...
def compress(self) -> None: ...
def encrypt(self) -> None: ...
2. Use Composition of Protocols
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
class Writable(Protocol):
def write(self, data: str) -> None: ...
class ReadWritable(Protocol):
def read(self) -> str: ...
def write(self, data: str) -> None: ...
# Or use composition
class FileHandler(Protocol):
def read(self) -> str: ...
def write(self, data: str) -> None: ...
class DatabaseHandler(Protocol):
def read(self) -> str: ...
def write(self, data: str) -> None: ...
# Both implement the same interface
def process_data(handler: ReadWritable) -> None:
data = handler.read()
processed_data = data.upper()
handler.write(processed_data)
3. Use Generic Protocols for Type Safety
from typing import Protocol, TypeVar, Generic
T = TypeVar('T')
class Container(Protocol[T]):
def add(self, item: T) -> None: ...
def remove(self, item: T) -> None: ...
def contains(self, item: T) -> bool: ...
class ListContainer:
def __init__(self):
self.items: list[T] = []
def add(self, item: T) -> None:
self.items.append(item)
def remove(self, item: T) -> None:
if item in self.items:
self.items.remove(item)
def contains(self, item: T) -> bool:
return item in self.items
class SetContainer:
def __init__(self):
self.items: set[T] = set()
def add(self, item: T) -> None:
self.items.add(item)
def remove(self, item: T) -> None:
self.items.discard(item)
def contains(self, item: T) -> bool:
return item in self.items
4. Document Protocol Intent
from typing import Protocol
class Cache(Protocol):
"""Protocol for cache implementations.
This protocol defines the interface for cache objects that can
store and retrieve key-value pairs.
"""
def get(self, key: str) -> str | None: ...
"""Retrieve a value by key. Returns None if key doesn't exist."""
def set(self, key: str, value: str) -> None: ...
"""Store a key-value pair in the cache."""
def delete(self, key: str) -> None: ...
"""Remove a key-value pair from the cache."""
Common Interview Questions
1. Basic Questions
Q: What is the difference between a Protocol and an Abstract Base Class?
# Protocol: Structural typing, no inheritance required
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle: # No inheritance needed
def draw(self) -> None:
print("Drawing circle")
# ABC: Nominal typing, inheritance required
from abc import ABC, abstractmethod
class DrawableABC(ABC):
@abstractmethod
def draw(self) -> None:
pass
class Rectangle(DrawableABC): # Must inherit
def draw(self) -> None:
print("Drawing rectangle")
Q: When would you use a Protocol instead of inheritance?
# Use Protocol when:
# 1. You want to work with existing classes you can't modify
# 2. You want to avoid tight coupling
# 3. You prefer composition over inheritance
class Logger(Protocol):
def log(self, message: str) -> None: ...
# Works with any class that has a log method
class FileLogger:
def log(self, message: str) -> None:
with open("app.log", "a") as f:
f.write(f"{message}\n")
class ConsoleLogger:
def log(self, message: str) -> None:
print(f"LOG: {message}")
def log_message(logger: Logger, message: str) -> None:
logger.log(message)
# Both work without inheritance
log_message(FileLogger(), "File log message")
log_message(ConsoleLogger(), "Console log message")
2. Advanced Questions
Q: How do you create a Protocol with optional methods?
from typing import Protocol, Optional
class Cache(Protocol):
def get(self, key: str) -> Optional[str]: ...
def set(self, key: str, value: str) -> None: ...
# Optional method - not required for compatibility
def clear(self) -> None: ...
class SimpleCache:
def __init__(self):
self._data: dict[str, str] = {}
def get(self, key: str) -> Optional[str]:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
self._data[key] = value
# Optional method - can be implemented or not
def clear(self) -> None:
self._data.clear()
class BasicCache:
def __init__(self):
self._data: dict[str, str] = {}
def get(self, key: str) -> Optional[str]:
return self._data.get(key)
def set(self, key: str, value: str) -> None:
self._data[key] = value
# No clear method - still compatible with Cache protocol
Q: How do you use Protocols with generic types?
from typing import Protocol, TypeVar, Generic, List
T = TypeVar('T')
class Collection(Protocol[T]):
def add(self, item: T) -> None: ...
def remove(self, item: T) -> None: ...
def get_all(self) -> List[T]: ...
class NumberCollection:
def __init__(self):
self.items: List[int] = []
def add(self, item: int) -> None:
self.items.append(item)
def remove(self, item: int) -> None:
if item in self.items:
self.items.remove(item)
def get_all(self) -> List[int]:
return self.items.copy()
class StringCollection:
def __init__(self):
self.items: List[str] = []
def add(self, item: str) -> None:
self.items.append(item)
def remove(self, item: str) -> None:
if item in self.items:
self.items.remove(item)
def get_all(self) -> List[str]:
return self.items.copy()
def process_collection(collection: Collection[T]) -> None:
print(f"Items: {collection.get_all()}")
# Type safety maintained
number_collection = NumberCollection()
string_collection = StringCollection()
process_collection(number_collection) # Works with int collection
process_collection(string_collection) # Works with str collection
3. Practical Questions
Q: How would you implement a plugin system using Protocols?
from typing import Protocol, Dict, Any, List
class Plugin(Protocol):
def initialize(self, config: Dict[str, Any]) -> None: ...
def process(self, data: Any) -> Any: ...
def cleanup(self) -> None: ...
class PluginManager:
def __init__(self):
self.plugins: List[Plugin] = []
def register(self, plugin: Plugin) -> None:
self.plugins.append(plugin)
def run_pipeline(self, data: Any) -> Any:
result = data
for plugin in self.plugins:
plugin.initialize({"mode": "production"})
result = plugin.process(result)
plugin.cleanup()
return result
class DataFilterPlugin:
def initialize(self, config: Dict[str, Any]) -> None:
self.config = config
def process(self, data: Any) -> Any:
if isinstance(data, list):
return [item for item in data if item is not None]
return data
def cleanup(self) -> None:
pass
class DataTransformPlugin:
def initialize(self, config: Dict[str, Any]) -> None:
self.config = config
def process(self, data: Any) -> Any:
if isinstance(data, list):
return [str(item).upper() for item in data]
return str(data).upper()
def cleanup(self) -> None:
pass
# Usage
manager = PluginManager()
manager.register(DataFilterPlugin())
manager.register(DataTransformPlugin())
input_data = [1, None, "hello", 3, None]
output = manager.run_pipeline(input_data)
print(output) # ['1', 'HELLO', '3']
Performance Considerations
1. Runtime Overhead
# Protocols have zero runtime overhead
from typing import Protocol
class Fast(Protocol):
def run(self) -> None: ...
class FastImplementation:
def run(self) -> None:
print("Running fast")
# No runtime cost - just type checking
def execute(fast: Fast) -> None:
fast.run()
# This is just a regular function call
execute(FastImplementation())
2. Type Checking Performance
# Protocols can be complex, but they're only checked at type-checking time
from typing import Protocol, TypeVar, Generic
T = TypeVar('T')
class ComplexProtocol(Protocol[T]):
def method1(self, arg: T) -> T: ...
def method2(self, arg: T) -> list[T]: ...
def method3(self, arg: list[T]) -> dict[str, T]: ...
# This complexity only affects mypy/pyright, not runtime
class SimpleImplementation:
def method1(self, arg: str) -> str:
return arg.upper()
def method2(self, arg: str) -> list[str]:
return [arg]
def method3(self, arg: list[str]) -> dict[str, str]:
return {"key": arg[0] if arg else ""}
3. Memory Usage
# Protocols don't add any memory overhead to instances
class Lightweight(Protocol):
def process(self) -> None: ...
class LightweightImpl:
def __init__(self):
self.data = "some data"
def process(self) -> None:
print(self.data)
# The instance has the same memory footprint
instance = LightweightImpl()
# No additional memory for protocol compatibility
Type Checking Integration
1. Mypy Configuration
# mypy.ini
[mypy]
python_version = 3.8
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
[mypy.plugins.protocols.*]
# Protocol-specific settings
2. Pyright Configuration
{
"include": ["src"],
"exclude": ["**/node_modules", "**/__pycache__"],
"typeCheckingMode": "strict",
"useLibraryCodeForTypes": true
}
3. IDE Support
# Most IDEs (PyCharm, VS Code) support Protocol syntax
from typing import Protocol
class MyProtocol(Protocol):
def my_method(self) -> str: ...
class MyClass:
def my_method(self) -> str:
return "Hello"
# IDE will provide autocomplete and type checking
def use_protocol(instance: MyProtocol) -> None:
result = instance.my_method() # IDE knows this returns str
print(result.upper()) # IDE provides string methods
Summary
Protocols in Python provide a powerful way to define structural interfaces:
- Structural Typing: Based on behavior, not inheritance
- Zero Runtime Overhead: Only affects static type checking
- Flexibility: Work with existing code without modification
- Type Safety: Provide compile-time type checking
- Composition: Encourage composition over inheritance
Protocols are particularly useful for:
- Plugin systems
- Dependency injection
- Testing with mocks
- Working with third-party libraries
- Defining clear interfaces without tight coupling
Choose Protocols when you want the benefits of interfaces without the constraints of inheritance hierarchies.
Interview angle
- “What is a Protocol?” — structural typing: a class satisfies it by having the right methods, with no inheritance or registration. If it has
read()andclose()with matching signatures, it’s aReadable, whether or not it ever heard of your Protocol. - “Protocol or ABC?” — Protocol when you don’t own the implementations, when you want duck typing that the type checker can verify, or when defining a port at an architectural boundary. ABC when you own the hierarchy, want shared implementation, and want instantiation to fail loudly on a missing method.
- “Is a Protocol checked at runtime?” — not by default; it’s a static-typing construct.
@runtime_checkableenablesisinstance, but that only verifies method names, not signatures or types, so it’s a weaker check than it appears. - “Why do Protocols suit dependency inversion?” — the interface can live with the consumer rather than the implementer, so the high-level module defines what it needs and low-level modules simply satisfy it. No import from the abstraction to the implementation, which is the dependency-inversion principle expressed cleanly.