Define Structural Interfaces with typing.Protocol
Use a Protocol to describe interchangeable objects and understand what runtime checks do and do not prove.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Define Structural Interfaces with typing.Protocol
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Python often cares about what an object can do, rather than which parent class it names. If a function needs an object with a `write` method, a file and a custom collector can both be useful, even if neither inherits from the same application-specific base class. `typing.Protocol` lets us describe that behavioral interface for static type checkers. It is called structural typing: an object fits the declared shape by providing compatible members. But there is an important distinction between a static type contract and a runtime test. A plain protocol cannot be used with `isinstance` unless it is marked runtime- checkable, and even then the runtime check is shallow.
- 00:43
We will demonstrate that limit, define a small `Writer` protocol, and run two concrete implementations through one function. No external type checker is required to follow the executable examples. The lesson page explains how static checking can catch signature mismatches before runtime, while the local runs show what Python itself actually enforces. Keep the two levels separate when reading results. The class `ListSink` provides a compatible-looking `write` method, but the program fails before it can report True or False. The reason is the use of `isinstance` with a plain protocol. By default, a protocol declaration is a static description for tools and readers,
- 01:26
not a runtime base class test. A static type checker can compare the declared method signatures and report mismatches without executing the program. Python itself does not automatically perform that full analysis every time a value crosses a function boundary. If your code needs a runtime guard, you can mark the protocol with `@runtime_checkable`, but that guard has narrower meaning than many people expect. It checks for member presence, not the full callable signature or semantics. We will use the decorator in the next run, then show its limitation explicitly. The TypeError here is useful: it stops us from assuming the static contract
- 02:09
magically became a complete runtime validator. The decorated protocol now permits an `isinstance` check. `ListSink` has a `write` attribute, so it passes; `NoSink` does not, so it fails. Neither class explicitly inherits from `Writer`. That is the structural idea in action. But this runtime check does not prove that `write` accepts one string argument, returns an integer, or behaves correctly. It only sees that a member with the required name is present. A static checker would use declared annotations to analyze more detail, and a real execution test would reveal whether the method actually works.
- 02:49
Therefore, use runtime-checkable protocols for broad capability checks, not as a substitute for validation of untrusted objects. There are also performance and version- specific details in the runtime mechanism, so repeated checks in a hot loop may not be the right architecture. In ordinary code, a clear function signature with a protocol annotation and focused tests is often sufficient. `write_message` names the behavior it needs: a `Writer` that accepts text and returns the number of characters written. `ListSink` stores the text, while `CountSink` only reports its length. Neither inherits from the protocol, yet both have the required method shape.
- 03:32
The executable program works because both methods genuinely accept the call; the annotation does not alter their runtime behavior. In a project, a static type checker can warn if a class provides `write` with an incompatible signature or return type before tests are run. This helps especially when several implementations share a behavioral contract. The protocol should remain small and focused. If it lists many unrelated methods, implementing it becomes harder and the interface describes less clearly what one function truly needs. Here one method is enough. We still write tests for the real behavior: that the list sink captured
- 04:13
`hello` and that both return the expected counts. Static checking and tests answer complementary questions. The first line is the surprising part: `WrongSink` passes the runtime protocol check because it has a member called `write`. The method does not accept the text argument, so calling it raises `TypeError`. We catch that expected failure and then verify a genuinely compatible sink. This example is not an argument against protocols; it is a reminder to use them for their intended layer. `Protocol` communicates and statically checks an interface. `@runtime_checkable` allows a limited runtime presence check.
- 04:54
Actual behavior still needs execution tests and, where inputs are untrusted, explicit validation. On the lesson page, change `GoodSink` to return a string instead of an integer. Python will still run, but a static type checker should flag that mismatch against the declared protocol. Then add a second writer implementation and run the same `write_message` function against both. The core concept is that compatibility can follow behavior rather than inheritance, provided we know which guarantees come from annotations, which come from runtime checks, and which come only from tests.
Understand the concept
Two classes can support the same operation without sharing a base class. A plain-text report and an HTML report might each implement render() and work in the same delivery function. Python's runtime already permits that kind of duck typing. typing.Protocol adds a named structural contract for type checkers: an object matches by having the required members, not by inheriting from the Protocol.
Define class Renderable(Protocol) with render(self) -> str. A function publish(item: Renderable) can then call item.render(). The annotation documents intent and helps a static checker detect callers whose classes lack the method or declare an incompatible signature. This browser exercise does not run mypy or Pyright; it demonstrates runtime behavior and explains the static promise separately. Python annotations by themselves do not stop a bad object at runtime.
@runtime_checkable permits isinstance(value, Renderable), but that check only looks for member presence. It does not check the type signature or whether render() returns str. A class with render = 3 can pass a presence check yet fail when called. Therefore the checked function render_item validates the callable member and return value explicitly before using it. That runtime validation is a lesson-specific boundary, not a replacement for static analysis.
Tests use unrelated classes with working render methods, a missing method, a noncallable attribute, and a method returning an integer. Keep the Protocol lean: only methods that callers really need should appear. This makes implementations easy to swap and avoids forcing unrelated classes into an inheritance tree.
- Structural typing matches members
- Protocol does not require inheritance
- Type hints are not runtime enforcement
- Runtime-checkable verifies presence, not signature
See it step by step
Read the code, predict the output, then compare it with the result.
01. Use unrelated objects through one operation
class Text:
def render(self): return 'hello'
class Html:
def render(self): return '<p>hello</p>'
print([item.render() for item in (Text(), Html())])['hello', '<p>hello</p>']The classes share behavior without a common parent.
02. Check only member presence at runtime
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
class Bad:
render = 3
print(isinstance(Bad(), Renderable))
print(callable(Bad().render))True
Falseruntime_checkable presence is weaker than a safe call or a static signature check.
03. Validate a callable's result
def safe_render(item):
method = getattr(item, 'render', None)
if not callable(method): raise TypeError('render method required')
value = method()
if not isinstance(value, str): raise TypeError('render must return str')
return value
print(safe_render(type('Page', (), {'render': lambda self: 'ok'})()))okRuntime validation checks the actual callable and result at the boundary.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Use a shared operation without inheritance
Text and Html have no common project-specific base class, but each implements render(). The first run calls that operation on both objects and prints two different strings. This is ordinary Python duck typing: behavior matters to the caller, while the class hierarchy can remain independent.
A small Protocol gives the operation a name for static type checkers. Declare only the members a caller truly needs, such as render() -> str. That makes the contract easier to satisfy and avoids forcing unrelated classes into one inheritance tree. The browser example demonstrates runtime calls; it does not run a static checker, so do not mistake the output for static proof.
class Text:
def render(self): return 'hello'
class Html:
def render(self): return '<p>hello</p>'
print([item.render() for item in (Text(), Html())])['hello', '<p>hello</p>']- Create two unrelated classes with the same method.
- Call render on both objects.
- Describe the shared member in a lean Protocol.
Expose the limit of runtime_checkable
The second run marks a Protocol runtime_checkable and gives Bad a render attribute containing the number 3. isinstance(Bad(), Renderable) prints True, yet callable(Bad().render) prints False. Runtime Protocol checks only inspect member presence; they do not prove a method signature or return type.
This is a useful boundary to teach explicitly. Static tools can analyze declarations and call sites, while runtime validation must inspect the behavior needed at the point of use. A presence check is neither a complete validator nor a reason to omit tests. If a caller will call render(), first ensure the attribute is callable and handle errors from the call.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
def render(self) -> str: ...
class Bad:
render = 3
print(isinstance(Bad(), Renderable))
print(callable(Bad().render))True
False- Add a noncallable member with the expected name.
- Compare the presence check to callable().
- Keep runtime and static guarantees distinct.
Check the actual result at the boundary
The final function retrieves render, rejects a missing or noncallable member, calls it, and checks that the result is text. The small Page fixture returns ok, so the example prints ok. That runtime check protects the exact operation this function performs without requiring a parent class.
The practical task should also reject a render method that returns an integer and an object whose render attribute is not callable. Keep the Protocol annotation to document the desired shape, but test observable behavior at inputs the program does not control. This pairs readable static intent with a deliberately small runtime boundary.
def safe_render(item):
method = getattr(item, 'render', None)
if not callable(method): raise TypeError('render method required')
value = method()
if not isinstance(value, str): raise TypeError('render must return str')
return value
print(safe_render(type('Page', (), {'render': lambda self: 'ok'})()))ok- Read the member with getattr and check callable().
- Call it and validate the returned type.
- Test missing, noncallable, and wrong-return fixtures.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Use method = getattr(item, 'render', None), check callable(method), call it once, and check isinstance(result, str).
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Assuming a Protocol requires inheritance
class Html(Renderable): ... # required for all implementationsclass Html:
def render(self) -> str: ...What happens: Unrelated useful implementations are needlessly coupled.
Structural typing accepts matching members without inheritance.
Using a presence check as complete validation
if isinstance(item, Renderable): return item.render()method = getattr(item, 'render', None)
if not callable(method): raise TypeError('render required')What happens: A noncallable render attribute may still pass the presence check.
Validate the runtime behavior this function relies on.