Liskov Substitution with typing.Protocol and mypy
Problem Statement
A recommendation use case needs a provider contract for car listings. The team wants low coupling and safe substitutions. The team wants static checks before runtime.
The contract must support many implementations:
- in-memory snapshot provider
- partner API provider
- cached feed provider
Each provider must satisfy one behavior contract. Client code must not change when provider type changes.
Constraints and Assumptions
- Python 3.14
mypyruns in CI- shared scenario from this handbook: Hypothetical Scenario
- provider contract returns a list of listing records
- empty list is valid output
Step by Step Implementation
Define domain types and a protocol contract
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Listing:
id: str
city: str
price: int
reliability: float
@dataclass(frozen=True)
class ListingQuery:
city: str
max_budget: int
class ListingProvider(Protocol):
def find(self, query: ListingQuery) -> list[Listing]:
...
Protocol defines required shape.
Any class with a compatible find method satisfies this contract.
No explicit inheritance is needed.
Implement compliant providers
class SnapshotListingProvider:
def find(self, query: ListingQuery) -> list[Listing]:
rows = [
Listing(id="car-801", city=query.city, price=18200, reliability=0.90),
Listing(id="car-802", city=query.city, price=23900, reliability=0.83),
]
return [row for row in rows if row.price <= query.max_budget]
class PartnerApiListingProvider:
def find(self, query: ListingQuery) -> list[Listing]:
rows = [
Listing(id="car-901", city=query.city, price=17800, reliability=0.88),
Listing(id="car-902", city=query.city, price=25800, reliability=0.79),
]
return [row for row in rows if row.price <= query.max_budget]
Both classes satisfy structural subtyping. Both classes can be substituted through the same contract.
Keep client code typed against the protocol
class MatchCandidatesUseCase:
def __init__(self, provider: ListingProvider):
self.provider = provider
def execute(self, query: ListingQuery) -> list[Listing]:
return self.provider.find(query)
This use case is polymorphic through the ListingProvider contract.
Liskov substitution holds when each provider preserves this contract.
Add a non-compliant provider and run mypy
class WrongReturnProvider:
def find(self, query: ListingQuery) -> dict:
return {"id": "car-x"}
use_case = MatchCandidatesUseCase(provider=WrongReturnProvider())
mypy reports a static type error for this assignment.
The provider does not satisfy the protocol contract.
Expected diagnostic shape:
error: Argument "provider" to "MatchCandidatesUseCase" has incompatible type "WrongReturnProvider"; expected "ListingProvider"
note: Following member(s) of "WrongReturnProvider" have conflicts:
note: Expected:
note: def find(self, query: ListingQuery) -> list[Listing]
note: Got:
note: def find(self, query: ListingQuery) -> dict[Any, Any]
Add behavior checks for true Liskov compliance
Static typing checks method shape. Static typing does not prove full behavioral substitution. Contract tests must verify behavior constraints such as:
- valid input range
- stable output semantics
- error policy
Example test:
def assert_provider_contract(provider: ListingProvider) -> None:
query = ListingQuery(city="Austin", max_budget=25000)
result = provider.find(query)
assert isinstance(result, list)
for item in result:
assert item.city == "Austin"
assert item.price <= 25000
Why This Works for Liskov Substitution
Polymorphism uses one contract in client code.
Structural subtyping validates compatible shapes.
mypy blocks shape violations before deployment.
Contract tests validate runtime behavior.
These four pieces work together:
- protocol contract
- polymorphic client code
- static checks with
mypy - contract tests for behavior
Verification Checklist
- run
mypyin local checks and CI - keep use case constructors typed with protocols
- reject providers that narrow valid inputs
- reject providers that change output semantics
- run contract tests across all providers
Common Failure Cases
- provider returns a different data type than contract
- provider raises undocumented errors for valid inputs
- provider mutates shared state in hidden ways
- protocol gets overloaded with unrelated methods
Written by: Pedro Guzmán
See References for complete APA-style bibliographic entries used on this page.