Python: Generic Request -> Response Protocol
Exhaustive Pattern Matching of Transport Deserialization
Is 100% static type coverage practical when datatypes represent communication with a remote resource?
If you are implementing a protocol that defines a Response (or Error) Type for every Request, it may seem like lots of code will be written just to satisfy the type system. Fortunately, with a Generic Protocol, you can write a single request function that is type safe for all implementations of Request and Response:
def request(request: Request[TResponse, TError]) -> TResponse | TError
The Problem and the Goal
For example, if we make a FooRequest
, then our request function should know that it is returning a FooResponse
.
foo_response = request(FooRequest())
reveal_type(foo_response) # we expect FooResponse
We could satisfy the type system explicity:
def request_foo(foo: FooRequest) -> FooResponse:
# do side effects on the transport layer
return FooResponse()
The problem is that we would need to write a bunch of request
functions that satisfy all the possible types. Next would be def request_bar(bar: BarRequest) -> BarResponse:
, and on and on!
But this is silly, because at compile-time (i.e. static analysis time), the type of literal arguments is known. What we really want is:
def request(request: "Type of request") -> "Type of the response to request":
Handling Request and Response Types
Imagine we have Request
and Response
interfaces that are implemented by Foo
and Bar
. Each Request
implementation represents some request to a server, and the Response
implementation represent the server's deserialized response. Below, I've filled in the implementations with ...
because they would be unique to each specification, transport, etc.
class Request: ...
class Response: ...
class FooRequest(Request): ...
class FooResponse(Response): ...
class Foo(FooRequest):
Response = FooResponse
class BarRequest(Request): ...
class BarResponse(Response): ...
class Bar(BarRequest):
Response: BarResponse
Now we have Foo
and Bar
classes with the class attribute Response
that points to the Type of Response. Manual usage could look like this:
raw_data = send(Foo())
foo_response = Foo.Response(raw_data)
Of course, because the Type of the Response is contained within the Request class, a function could handle this generically.
def request(request: Request) -> Response:
raw_data = send(request)
# depends on how deserialization is implemented:
return request.Response.loads(raw_data)
This is not bad! The Python type system understands that the request function takes some implementation of the Request Type, and returns some implementation of the Response Type. And yet, the type system does not know what request was sent, and it especially doesn't know what response was received. We can fix that!
foo_response = request(Foo())
reveal_type(foo_response) # Response 😔
bar_response = request(Bar())
reveal_type(bar_response) # Response 😔
Note: you could get more flexibility by adding a Request class variable to the class instead of inheriting from the Request.
The Request Generic Protocol
There is a simple improvement that we can make to satisfy our goal:
def request(request: "Type of request") -> "Type of the response to request":
We will add a Generic Protocol so that the type checker can infer the type of the Response from the type of the Request before runtime.
from typing import Protocol, Type, TypeVar
TResponse = TypeVar("TResponse", bound=Response)
class RequestProtocol(Protocol[TResponse]) -> TResponse:
Response: Type[TResponse]
Note:
Protocol[T, ...]
is shorthand forProtocol, Generic[T, ...]
, link
Now, the we can achieve our goal of communicating that the Type of a Response is dependent on the Type of a Request, information that is known before runtime:
def request(request: RequestProtocol[TResponse]) -> TResponse:
...
Usage would look like this and the static typing will work as intended:
foo_response = request(Foo())
reveal_type(foo_response) # FooResponse 🤩
bar_response = request(Bar())
reveal_type(bar_response) # BarResponse 🤩
Real World Example
A production OSS example is the smpclient library which implements firmware updates and other controls of small embedded systems over serial (USB), BLE, and UDP (network). The request method of the SMP Client maps a generic Request to the corresponding Response, ErrorV1, and ErrorV2 of the Simple Management Protocol specification.
Reality is a lot more complicated than Request -> Response. So how do we deal with a specification that might fail in various ways while maintaining type safety?
Exhaustive Pattern Matching
I've put together an example that you can run on mypy playground or directly in your own Python interpreter.
It is a slightly more realistic expansion of the Generic Protocol described above. The main difference is that this server can return an Error as well as a Response. In order to prevent runtime errors, we'd like to ensure that for every Request, we've handled every Response. Fortunately, this is doable with Python 3.10+'s Structural Pattern Matching:
match status := await request(Status()):
case StatusResponse():
reveal_type(status)
case StatusError():
reveal_type(status)
case _:
assert_never(status)
The case _:
statement, as in Rust, is followed if the pattern is unmatched above. Executing typing.assert_never(status)
here provides a compile-time check that every return type of the request function has been handled.
Note: The
match
statement will not work in Python versions before 3.10. Python 3.9 EOL is in October of 2025, so if you are maintaining a library, it may be most polite to wait until Python 3.9 is retired.
Check it out on mypy Playground or take a look at the corresponding GitHub Gist below:
Conclusion
I believe that all Python libraries benefit from 100% static typing coverage. In this post, I showed how Generic Protocols can alleviate some of the repetition encountered as more advanced type system patterns are implemented in Python. Every error that gets caught by the type system prevents a runtime bug, saving time and money.
Thanks for reading! Do you use Protocols and Generics in Python? What approaches do you take for handling de/serialization of custom communications protocols?