Skip to main content

Python Protocol Practice Problems & Exercises

Practice: Protocol and Structural Subtyping

11 problems3 Easy4 Medium4 Hard70–90 min
← Back to lesson

Easy

#1Define a Drawable ProtocolEasy
Protocolstructural-subtypingduck-typing

Define a Drawable protocol with a draw() method. Write three unrelated classes that structurally satisfy it and a render() function that accepts any Drawable.

Python
from typing import Protocol


class Drawable(Protocol):
    def draw(self) -> str:
        ...


class Circle:
    def __init__(self, radius: int) -> None:
        self.radius = radius

    def draw(self) -> str:
        return f"Drawing Circle(radius={self.radius})"


class Square:
    def __init__(self, side: int) -> None:
        self.side = side

    def draw(self) -> str:
        return f"Drawing Square(side={self.side})"


class Triangle:
    def __init__(self, base: int, height: int) -> None:
        self.base = base
        self.height = height

    def draw(self) -> str:
        return f"Drawing Triangle(base={self.base}, height={self.height})"


def render(shape: Drawable) -> None:
    print(shape.draw())


shapes = [Circle(5), Square(4), Triangle(3, 6)]
for s in shapes:
    render(s)
Expected Output
Drawing Circle(radius=5)
Drawing Square(side=4)
Drawing Triangle(base=3, height=6)
Hints

Hint 1: Define a Protocol with a single draw() method. Any class that has draw() satisfies it, regardless of inheritance.

Hint 2: The render function accepts Drawable — any object with draw() works, no registration needed.


#2runtime_checkable Protocol with isinstance()Easy
Protocolruntime_checkableisinstance

Create a @runtime_checkable Quackable protocol. Show that isinstance() checks work on arbitrary objects.

Python
from typing import Protocol, runtime_checkable


@runtime_checkable
class Quackable(Protocol):
    def quack(self) -> str:
        ...


class Duck:
    def quack(self) -> str:
        return "Quack!"


class Person:
    def quack(self) -> str:
        return "I'm quacking like a duck!"


class Cat:
    def meow(self) -> str:
        return "Meow!"


duck = Duck()
person = Person()
cat = Cat()

print(f"duck is Quackable: {isinstance(duck, Quackable)}")
print(f"person is Quackable: {isinstance(person, Quackable)}")
print(f"cat is Quackable: {isinstance(cat, Quackable)}")
Expected Output
duck is Quackable: True
person is Quackable: True
cat is Quackable: False
Hints

Hint 1: Decorate the Protocol class with @runtime_checkable. This enables isinstance() checks at runtime.

Hint 2: isinstance only checks for the presence of methods — it does not verify their signatures.


#3Sortable Protocol — Key-Based OrderingEasy
Protocolordering__lt__structural

Define a Comparable protocol with __lt__. Write a Product class that satisfies it, then use sorted() and min() with the protocol-typed function.

Python
from typing import Protocol, Any, List, TypeVar

C = TypeVar("C", bound="Comparable")


class Comparable(Protocol):
    def __lt__(self, other: Any) -> bool:
        ...


def sort_items(items: List[C]) -> List[C]:
    return sorted(items)


def minimum(items: List[C]) -> C:
    return min(items)


class Product:
    def __init__(self, name: str, price: float) -> None:
        self.name = name
        self.price = price

    def __lt__(self, other: "Product") -> bool:
        return self.price < other.price

    def __repr__(self) -> str:
        return f"Product(price={self.price})"


products = [
    Product("Laptop", 29.99),
    Product("Cable", 5.0),
    Product("Keyboard", 12.99),
]

sorted_products = sort_items(products)
print(f"Sorted products: {sorted_products}")
print(f"Min: {minimum(products)}")
Expected Output
Sorted products: [Product(price=5.0), Product(price=12.99), Product(price=29.99)]
Min: Product(price=5.0)
Hints

Hint 1: Define a Comparable protocol with __lt__(self, other: Any) -> bool.

Hint 2: Any class implementing __lt__ satisfies the protocol — it works with sorted() and min().


Medium

#4Serializable Protocol — Multiple ImplementationsMedium
Protocolserializationstructural-subtypingmultiple-implementations

Define a Serializable protocol with to_dict() and a classmethod from_dict(). Implement it on two unrelated classes and write a generic serialize/deserialize pipeline.

Python
from typing import Protocol, Dict, Any, Type, TypeVar
import json

T = TypeVar("T", bound="Serializable")


class Serializable(Protocol):
    def to_dict(self) -> Dict[str, Any]:
        ...

    @classmethod
    def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
        ...


class User:
    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name = name

    def to_dict(self) -> Dict[str, Any]:
        return {"id": self.id, "name": self.name}

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "User":
        return cls(data["id"], data["name"])

    def __repr__(self) -> str:
        return f"User(id={self.id}, name={self.name!r})"


class Point:
    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def to_dict(self) -> Dict[str, Any]:
        return {"x": self.x, "y": self.y}

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "Point":
        return cls(data["x"], data["y"])


def to_json(obj: Serializable) -> str:
    return json.dumps(obj.to_dict())


u = User(1, "Alice")
p = Point(10, 20)

print(f"JSON: {to_json(u)}")
print(f"JSON: {to_json(p)}")

# Round-trip
restored = User.from_dict(u.to_dict())
print(f"Roundtrip OK: {restored.to_dict() == u.to_dict()}")
Expected Output
JSON: {"id": 1, "name": "Alice"}
JSON: {"x": 10, "y": 20}
Roundtrip OK: True
Hints

Hint 1: Define Serializable with to_dict() -> dict and from_dict(cls, data: dict) -> Self as a classmethod.

Hint 2: Both User and Point implement the protocol without inheriting from a common base.


#5Composing Protocols via InheritanceMedium
Protocolcompositioninheritancemulti-method

Create Readable and Writable protocols, then compose them into ReadableWritable. Implement FileStream to satisfy the combined protocol.

Python
from typing import Protocol, runtime_checkable


class Readable(Protocol):
    def read(self) -> str:
        ...


class Writable(Protocol):
    def write(self, data: str) -> bool:
        ...


@runtime_checkable
class ReadableWritable(Readable, Writable, Protocol):
    """Composed protocol: must implement both read and write."""
    pass


class FileStream:
    def __init__(self, initial: str = "") -> None:
        self._buffer = initial

    def read(self) -> str:
        return self._buffer

    def write(self, data: str) -> bool:
        self._buffer = data
        return True


def process_stream(stream: ReadableWritable) -> None:
    stream.write("hello world")
    content = stream.read()
    print(f"read: {content}")
    print(f"write complete: True")


fs = FileStream()
print(f"ReadableWritable check: {isinstance(fs, ReadableWritable)}")
print(f"FileStream satisfies ReadableWritable: {isinstance(fs, ReadableWritable)}")
process_stream(fs)
Expected Output
ReadableWritable check: True
FileStream satisfies ReadableWritable: True
read: hello world
write complete: True
Hints

Hint 1: Protocol classes can inherit from other Protocols to compose requirements: class ReadableWritable(Readable, Writable, Protocol): ...

Hint 2: Any class implementing all methods from both parent protocols satisfies the composed protocol.


#6Protocol with PropertiesMedium
Protocolpropertystructural-subtyping

Define a Shape protocol with area and perimeter properties. Write a Circle implementation and a validator function.

Python
from typing import Protocol, runtime_checkable
import math


@runtime_checkable
class Shape(Protocol):
    @property
    def area(self) -> float:
        ...

    @property
    def perimeter(self) -> float:
        ...


class Circle:
    def __init__(self, radius: float) -> None:
        self._radius = radius

    @property
    def area(self) -> float:
        return math.pi * self._radius ** 2

    @property
    def perimeter(self) -> float:
        return 2 * math.pi * self._radius


class Rectangle:
    def __init__(self, w: float, h: float) -> None:
        self._w = w
        self._h = h

    @property
    def area(self) -> float:
        return self._w * self._h

    @property
    def perimeter(self) -> float:
        return 2 * (self._w + self._h)


def describe(shape: Shape) -> None:
    print(f"area = {shape.area}")
    print(f"perimeter = {shape.perimeter}")
    print(f"Validates: {isinstance(shape, Shape)}")


describe(Circle(5))
Expected Output
area = 78.53981633974483
perimeter = 31.41592653589793
Validates: True
Hints

Hint 1: Declare @property methods inside the Protocol class body. Implementations must provide matching properties.

Hint 2: The protocol checks for the method name — both @property and regular methods with the same name satisfy it at runtime.


#7Context Manager ProtocolMedium
Protocol__enter____exit__context-manager

Define a ContextManagerProtocol and implement a Transaction class that satisfies it. Use it with a with statement.

Python
from typing import Protocol, Optional, Type
from types import TracebackType


class ContextManagerProtocol(Protocol):
    def __enter__(self) -> "ContextManagerProtocol":
        ...

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> Optional[bool]:
        ...


class Transaction:
    def __init__(self, name: str) -> None:
        self.name = name
        self._committed = False

    def __enter__(self) -> "Transaction":
        print(f"Entering {self.name}")
        return self

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_val: Optional[BaseException],
        exc_tb: Optional[TracebackType],
    ) -> Optional[bool]:
        success = exc_type is None
        print(f"Exiting {self.name} (success={success})")
        if success:
            self._committed = True
            print(f"Transaction committed")
        else:
            print(f"Transaction rolled back")
        return False   # don't suppress exceptions


def run_with(cm: ContextManagerProtocol) -> str:
    with cm:
        print("Operation 1 completed")
        print("Operation 2 completed")
    return "success"


t = Transaction("transaction")
result = run_with(t)
print(f"Result: {result}")
Expected Output
Entering transaction
Operation 1 completed
Operation 2 completed
Exiting transaction (success=True)
Transaction committed
Result: success
Hints

Hint 1: Define a ContextManagerProtocol with __enter__(self) -> T and __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool].

Hint 2: A class satisfying the protocol can be used with the "with" statement directly.


Hard

#8Iterator Protocol ImplementationHard
Protocol__iter____next__iteratoriterable

Define Iterable[T] and Iterator[T] protocols manually, then implement a FibonacciIterator that satisfies both. Write a generic take(n, iterable) function.

Python
from typing import Protocol, TypeVar, Generic, List

T = TypeVar("T")


class IteratorProtocol(Protocol[T]):
    def __next__(self) -> T:
        ...

    def __iter__(self) -> "IteratorProtocol[T]":
        ...


class IterableProtocol(Protocol[T]):
    def __iter__(self) -> IteratorProtocol[T]:
        ...


class FibonacciIterator:
    def __init__(self, limit: int) -> None:
        self._limit = limit
        self._count = 0
        self._a = 0
        self._b = 1

    def __iter__(self) -> "FibonacciIterator":
        return self

    def __next__(self) -> int:
        if self._count >= self._limit:
            raise StopIteration
        result = self._a
        self._a, self._b = self._b, self._a + self._b
        self._count += 1
        return result


def take(n: int, iterable: IterableProtocol) -> List:
    result = []
    for item in iterable:
        result.append(item)
        if len(result) >= n:
            break
    return result


fib = FibonacciIterator(10)
values = take(10, fib)
print(f"Fibonacci: {values}")
print(f"Sum via protocol: {sum(values)}")

# Verify it works with for loop
total = 0
for v in FibonacciIterator(5):
    total += v
print(f"Works with for loop: {total == 4}")
Expected Output
Fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Sum via protocol: 78
Works with for loop: True
Hints

Hint 1: Define Iterable[T] and Iterator[T] protocols. Iterator must have __next__() -> T and __iter__() -> Iterator[T].

Hint 2: FibonacciIterator implements both by returning self from __iter__ and computing the next value in __next__.


#9Strategy Pattern via ProtocolHard
Protocolstrategy-patterndependency-injectionstructural

Use a SortStrategy protocol to implement three sorting strategies. Inject them into a Sorter class and verify they all produce the same result.

Python
from typing import Protocol, TypeVar, List

T = TypeVar("T")


class SortStrategy(Protocol):
    def sort(self, items: List[int]) -> List[int]:
        ...


class BubbleSort:
    def sort(self, items: List[int]) -> List[int]:
        arr = list(items)
        n = len(arr)
        for i in range(n):
            for j in range(0, n - i - 1):
                if arr[j] > arr[j + 1]:
                    arr[j], arr[j + 1] = arr[j + 1], arr[j]
        return arr


class MergeSort:
    def sort(self, items: List[int]) -> List[int]:
        if len(items) <= 1:
            return list(items)
        mid = len(items) // 2
        left = self.sort(items[:mid])
        right = self.sort(items[mid:])
        return self._merge(left, right)

    def _merge(self, left: List[int], right: List[int]) -> List[int]:
        result = []
        i = j = 0
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:
                result.append(left[i]); i += 1
            else:
                result.append(right[j]); j += 1
        return result + left[i:] + right[j:]


class TimSort:
    def sort(self, items: List[int]) -> List[int]:
        return sorted(items)


class Sorter:
    def __init__(self, strategy: SortStrategy) -> None:
        self._strategy = strategy

    def sort(self, items: List[int]) -> List[int]:
        return self._strategy.sort(items)


data = [5, 3, 8, 1, 2]
strategies = [
    ("BubbleSort", BubbleSort()),
    ("MergeSort", MergeSort()),
    ("TimSort", TimSort()),
]

results = []
for name, strategy in strategies:
    sorter = Sorter(strategy)
    result = sorter.sort(data)
    results.append(result)
    print(f"{name}: {result}")

print(f"All strategies agree: {all(r == results[0] for r in results)}")
Expected Output
BubbleSort: [1, 2, 3, 5, 8]
MergeSort: [1, 2, 3, 5, 8]
TimSort: [1, 2, 3, 5, 8]
All strategies agree: True
Hints

Hint 1: Define a SortStrategy protocol with sort(items: List[T]) -> List[T]. Each strategy implements the method independently.

Hint 2: A Sorter class accepts a SortStrategy at construction time and delegates to it.


#10Repository Protocol with Generic TypeHard
ProtocolGenericrepository-patternTypeVarCRUD

Define a generic Repository[T] protocol with CRUD operations. Implement an InMemoryRepository[T] and use it with a User entity.

Python
from typing import Protocol, TypeVar, Generic, Optional, List, Dict

T = TypeVar("T")
IdType = TypeVar("IdType")


class HasId(Protocol):
    @property
    def id(self) -> int:
        ...


class Repository(Protocol[T]):
    def save(self, entity: T) -> T:
        ...

    def find_by_id(self, entity_id: int) -> Optional[T]:
        ...

    def find_all(self) -> List[T]:
        ...

    def delete(self, entity_id: int) -> bool:
        ...


class InMemoryRepository(Generic[T]):
    def __init__(self) -> None:
        self._store: Dict[int, T] = {}

    def save(self, entity: T) -> T:
        entity_id = getattr(entity, "id")
        self._store[entity_id] = entity
        return entity

    def find_by_id(self, entity_id: int) -> Optional[T]:
        return self._store.get(entity_id)

    def find_all(self) -> List[T]:
        return list(self._store.values())

    def delete(self, entity_id: int) -> bool:
        if entity_id in self._store:
            del self._store[entity_id]
            return True
        return False


class User:
    def __init__(self, id: int, name: str) -> None:
        self.id = id
        self.name = name

    def __repr__(self) -> str:
        return f"User(id={self.id}, name={self.name!r})"


repo: InMemoryRepository[User] = InMemoryRepository()

u1 = repo.save(User(1, "Alice"))
u2 = repo.save(User(2, "Bob"))
print(f"Saved {u1}")
print(f"Found: {repo.find_by_id(1)}")
print(f"All: {repo.find_all()}")
print(f"Deleted 1: {repo.delete(1)}")
print(f"All after delete: {repo.find_all()}")
Expected Output
Saved User(id=1, name='Alice')
Found: User(id=1, name='Alice')
All: [User(id=1, name='Alice'), User(id=2, name='Bob')]
Deleted 1: True
All after delete: [User(id=2, name='Bob')]
Hints

Hint 1: Combine Protocol and Generic[T]: class Repository(Protocol[T]) with save, find_by_id, find_all, delete methods.

Hint 2: InMemoryRepository[T] uses a dict as storage. The TypeVar ensures the methods consistently work on the same entity type.


#11Plugin System with Protocol ValidationHard
Protocolpluginvalidationruntime_checkablefactory

Build a plugin registry that validates plugins against a @runtime_checkable Protocol before registration. Route data to the correct plugin by name.

Python
from typing import Protocol, runtime_checkable, Dict, List


@runtime_checkable
class PluginProtocol(Protocol):
    @property
    def name(self) -> str:
        ...

    def process(self, data: str) -> str:
        ...

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


class PluginRegistry:
    def __init__(self) -> None:
        self._plugins: Dict[str, PluginProtocol] = {}

    def register(self, plugin: object) -> None:
        if not isinstance(plugin, PluginProtocol):
            raise TypeError(
                f"{type(plugin).__name__} does not implement PluginProtocol"
            )
        p = plugin  # type: ignore[assignment]
        if not p.validate():
            raise ValueError(f"Plugin '{p.name}' failed validation")
        self._plugins[p.name] = p
        print(f"Registered: {p.name}")

    def run(self, plugin_name: str, data: str) -> str:
        if plugin_name not in self._plugins:
            raise KeyError(f"Plugin '{plugin_name}' not found")
        return self._plugins[plugin_name].process(data)


class TextPlugin:
    @property
    def name(self) -> str:
        return "TextPlugin"

    def process(self, data: str) -> str:
        return data.upper()

    def validate(self) -> bool:
        return True


class NumberPlugin:
    @property
    def name(self) -> str:
        return "NumberPlugin"

    def process(self, data: str) -> str:
        return str(len(data))

    def validate(self) -> bool:
        return True


class InvalidPlugin:
    # Missing process() and validate()
    def name(self) -> str:  # not a property
        return "invalid"


registry = PluginRegistry()
registry.register(TextPlugin())
registry.register(NumberPlugin())

print(f"TextPlugin output: {registry.run('TextPlugin', 'hello world')}")
print(f"NumberPlugin output: {registry.run('NumberPlugin', 'hello world')}")

try:
    registry.register(InvalidPlugin())
    print("Invalid plugin accepted (wrong)")
except TypeError:
    print("Invalid plugin rejected: True")
Expected Output
Registered: TextPlugin
Registered: NumberPlugin
TextPlugin output: HELLO WORLD
NumberPlugin output: 42
Invalid plugin rejected: True
Hints

Hint 1: A PluginProtocol defines name (property), process(data: str) -> str, and validate() -> bool.

Hint 2: A PluginRegistry registers plugins, validates them via isinstance with the runtime_checkable Protocol, and routes data to the right plugin.

© 2026 EngineersOfAI. All rights reserved.