Skip to content
aviral gupta

// A2.3 · ~30 min · Advanced

Structural typing with Protocol

After this lesson you can describe what an object must do with a Protocol, check it at runtime within the limits of that check, and choose between a Protocol and an ABC.

Lesson 3 of 6 in A2 Typing

You will be able to

  • Define a Protocol and use it as a type that any class with the right members satisfies
  • Check protocols at runtime with @runtime_checkable, and know what that check does not test
  • Choose between a Protocol and an ABC: structural or nominal, and what each does at runtime
  1. Warm-up · Activity 1 of 7

    Warm-up from module A1: which methods must a class define so that len(x) and for item in x work on its objects?

  2. Predict · Activity 2 of 7

    Predict before you read on: File does not inherit from Closer. What does this print?

    from typing import Protocol
    
    
    class Closer(Protocol):
        def close(self) -> str: ...
    
    
    class File:
        def close(self) -> str:
            return "file closed"
    
    
    def shut(item: Closer) -> str:
        return item.close()
    
    
    print(shut(File()), Closer in File.__mro__)
  3. Practice · Activity 3 of 7

    Fill in the base class that makes Closer a protocol, so that any class with a close() method matches it.

    class Closer(____):
    class Closer():
  4. Practice · Activity 4 of 7

    mypy rejects the last line, and adds notes that compare Door with Closer. What does it show under Got:?

    from typing import Protocol
    
    
    class Closer(Protocol):
        def close(self) -> None: ...
    
    
    class Door:
        def close(self, force: bool) -> None:
            pass
    
    
    def close_one(item: Closer) -> None:
        item.close()
    
    
    close_one(Door())
  5. Practice · Activity 5 of 7

    Match each piece of code to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Door.close takes an extra argument and returns a str. What does this print?

    from typing import Protocol, runtime_checkable
    
    
    @runtime_checkable
    class Closer(Protocol):
        def close(self) -> None: ...
    
    
    class Door:
        def close(self, force: bool) -> str:
            return "slam"
    
    
    print(isinstance(Door(), Closer))
  7. Apply · Activity 7 of 7

    Mini-task. Write a protocol Writer with one method, write(self, text: str) -> int, and a function log(target: Writer, message: str) that writes "[log] " plus the message and a newline. Call it with an io.StringIO and with sys.stdout: neither inherits from Writer, and mypy --strict should accept both.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A protocol and an ABC side by side

Closer is a runtime-checkable protocol; Resource is an ABC with an abstract close() and a shared describe(). Connection inherits from Resource, so it gets describe() and must implement close(). TempFile inherits from nothing, yet close_all accepts both, because both have close(). The last lines show which runtime checks see which relationship.

main.py

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable


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


class Resource(ABC):
    """A family of our own classes: shared code plus a required method."""

    def __init__(self, name: str) -> None:
        self.name = name

    @abstractmethod
    def close(self) -> None: ...

    def describe(self) -> str:
        return f"{type(self).__name__} {self.name}"


class Connection(Resource):
    def close(self) -> None:
        print("closing", self.describe())


class TempFile:  # no base class: it only has the right method
    def close(self) -> None:
        print("deleting a temporary file")


def close_all(items: list[Closer]) -> None:
    for item in items:
        item.close()


close_all([Connection("db"), TempFile()])
print(isinstance(TempFile(), Closer), isinstance(TempFile(), Resource))
print(isinstance(Connection("db"), Closer))

Run it with

python main.py

Output

closing Connection db
deleting a temporary file
True False
True
  • close_all asks only for close(), so it takes classes from anywhere, including ones you cannot change.
  • TempFile is a Closer but not a Resource: the ABC counts only subclasses.
  • Connection is both: it inherits from Resource and has close(), so the protocol matches it too.
  • Connection("db") works because it overrides the abstract close(); Resource("db") would raise TypeError.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Shapes you cannot change

Square and Circle come from a library you cannot change, and they do not inherit from HasArea. Turn HasArea from an ABC into a runtime-checkable Protocol, so both classes fit total_area without changes. Then write only_shapes(items), which keeps the items that have an area() method, using isinstance() with HasArea.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Replace ABC with Protocol and drop @abstractmethod: a protocol method has only ... as its body.

  2. Hint 2

    Without @runtime_checkable, isinstance(item, HasArea) raises TypeError. Import it from typing and decorate the class.

  3. Hint 3

    only_shapes is one list comprehension: [item for item in items if isinstance(item, HasArea)].

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import math
from typing import Protocol, runtime_checkable


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


# From a library: do not change these two classes.
class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2


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

    def area(self) -> float:
        return math.pi * self.radius**2


def total_area(shapes: list[HasArea]) -> float:
    return sum(shape.area() for shape in shapes)


def only_shapes(items: list[object]) -> list[HasArea]:
    return [item for item in items if isinstance(item, HasArea)]
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import math
from abc import ABC, abstractmethod


class HasArea(ABC):
    @abstractmethod
    def area(self) -> float: ...


# From a library: do not change these two classes.
class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2


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

    def area(self) -> float:
        return math.pi * self.radius**2


def total_area(shapes: list[HasArea]) -> float:
    return sum(shape.area() for shape in shapes)


def only_shapes(items: list[object]) -> list[HasArea]:
    return []

test_main.py

import math
from typing import is_protocol

from main import Circle, HasArea, Square, only_shapes, total_area


def test_is_protocol():
    """HasArea is a Protocol, not an ABC"""
    assert is_protocol(HasArea), "HasArea should inherit from typing.Protocol"


def test_runtime_checkable():
    """isinstance() works with HasArea, and Square and Circle match it"""
    got = isinstance(Square(2), HasArea), isinstance(Circle(1), HasArea), isinstance("text", HasArea)
    assert got == (True, True, False), f"isinstance() gave {got!r}, expected (True, True, False)"


def test_total_area():
    """total_area adds 4 for a square of side 2 and pi for a circle of radius 1"""
    got = total_area([Square(2), Circle(1)])
    assert math.isclose(got, 4 + math.pi), f"total_area returned {got!r}, expected {4 + math.pi!r}"


def test_only_shapes():
    """only_shapes keeps the square and the circle, and drops the rest"""
    square, circle = Square(1), Circle(1)
    got = only_shapes([square, "text", 3, circle])
    assert got == [square, circle], f"only_shapes returned {got!r}, expected the square and the circle"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

An abstract exporter

Here the classes are your own, and they share export(), so an ABC fits. Make Exporter an ABC with an abstract render(rows); export(rows) stays a shared method. Exporter() and any subclass without render() must then fail with TypeError. Finish JsonExporter: render returns json.dumps(rows).

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    from abc import ABC, abstractmethod, then class Exporter(ABC): and put @abstractmethod above render.

  2. Hint 2

    An abstract method may keep a body; ... is enough, because no one calls it directly.

  3. Hint 3

    JsonExporter needs its own render(self, rows) that returns json.dumps(rows).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import json
from abc import ABC, abstractmethod


class Exporter(ABC):
    """Base class for exporters: subclasses must implement render()."""

    @abstractmethod
    def render(self, rows: list[dict[str, str]]) -> str: ...

    def export(self, rows: list[dict[str, str]]) -> str:
        if not rows:
            return ""
        return self.render(rows) + "\n"


class CsvExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        header = ",".join(rows[0])
        lines = [",".join(row.values()) for row in rows]
        return "\n".join([header, *lines])


class JsonExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        return json.dumps(rows)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import json


class Exporter:
    """Base class for exporters: subclasses must implement render()."""

    def render(self, rows: list[dict[str, str]]) -> str:
        raise NotImplementedError

    def export(self, rows: list[dict[str, str]]) -> str:
        if not rows:
            return ""
        return self.render(rows) + "\n"


class CsvExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        header = ",".join(rows[0])
        lines = [",".join(row.values()) for row in rows]
        return "\n".join([header, *lines])


class JsonExporter(Exporter):
    pass

test_main.py

import json

from main import CsvExporter, Exporter, JsonExporter

ROWS = [{"name": "Ada", "city": "London"}, {"name": "Alan", "city": "Wilmslow"}]


def test_base_is_abstract():
    """Exporter() raises TypeError"""
    try:
        Exporter()
    except TypeError:
        return
    assert False, "Exporter() created an object; make render an @abstractmethod of an ABC"


def test_missing_render():
    """A subclass without render() cannot be instantiated"""

    class Broken(Exporter):
        pass

    try:
        Broken()
    except TypeError:
        return
    assert False, "a subclass without render() was instantiated"


def test_csv():
    """CsvExporter writes a header line and one line per row"""
    got = CsvExporter().export(ROWS)
    assert got == "name,city\nAda,London\nAlan,Wilmslow\n", f"CsvExporter().export returned {got!r}"


def test_json():
    """JsonExporter writes the rows as JSON, plus a newline"""
    got = JsonExporter().export(ROWS)
    assert got == json.dumps(ROWS) + "\n", f"JsonExporter().export returned {got!r}"


def test_empty():
    """export([]) returns an empty string"""
    got = JsonExporter().export([])
    assert got == "", f"export([]) returned {got!r}, expected ''"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

isinstance() with a plain protocol

from typing import Protocol


class Closer(Protocol):
    def close(self) -> None: ...


class File:
    def close(self) -> None:
        pass


print(isinstance(File(), Closer))

What Python prints

TypeError: Instance and class checks can only be used with @runtime_checkable protocols

Why, and the fix

A protocol is for the type checker by default. To test it at runtime, decorate it: from typing import runtime_checkable, then @runtime_checkable above class Closer(Protocol):. Remember that the check then tests only that close exists, not its signature. mypy flags the original line too: Only @runtime_checkable protocols can be used with instance and class checks [misc].

Instantiating a protocol

from typing import Protocol


class Closer(Protocol):
    def close(self) -> None: ...


item = Closer()

What Python prints

TypeError: Protocols cannot be instantiated

Why, and the fix

A protocol describes objects; it is not one itself. Create an object of a class that has the members, and annotate with the protocol: item: Closer = File(). If you want shared default methods, a class can inherit from the protocol explicitly and is then instantiable, or use an ABC.

Forgetting an abstract method in a subclass

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


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


Square(2)

What Python prints

TypeError: Can't instantiate abstract class Square without an implementation for abstract method 'area'

Why, and the fix

An ABC refuses to create an object while any @abstractmethod is not overridden. That is its runtime guarantee, and the error names the missing method. Add def area(self) -> float: return self.side ** 2 to Square. mypy reports the same before running: Cannot instantiate abstract class "Square" with abstract attribute "area".

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A Protocol names what an object can do

class Closer(Protocol): def close(self) -> None: ... lists members; the ... is the whole body. Any class with a matching close() is a Closer for mypy, without inheriting from it: that is structural typing, static duck typing. mypy compares whole signatures, so close(self, force: bool) does not match, and its note shows Expected and Got. Attributes count too: name: str. A Protocol cannot be instantiated. A class may still inherit from one explicitly to take over its default method bodies.

@runtime_checkable: a presence check only

isinstance(x, Closer) raises TypeError unless the protocol is decorated with @runtime_checkable. With it, isinstance() checks only that the members exist: not their signatures, parameter types or return types. A close(self, force) method passes; an attribute close = None counts as missing. A protocol with data members such as name: str works with isinstance() but not with issubclass(). Use the runtime check for coarse sorting of objects, and leave correctness to mypy.

Protocol or ABC

An abstract base class is nominal: a class is a Resource only if it inherits from it or is registered with Resource.register(). In return, an ABC works at runtime: a subclass that misses an @abstractmethod cannot be instantiated, and concrete methods such as describe() are shared. register() makes isinstance() true but checks and shares nothing. Use a Protocol to state what your function needs from objects you do not control; use an ABC for a family of your own classes with common code.

Sources

Last reviewed September 29, 2026