Skip to content

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
  • mypy runs 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 mypy in 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.