Improving type safety in Python interfaces: a poor man's dependent types

Assume we have a helper function for handling HTTP requests: it parses an incoming request into different models (a model for the body, route & query params), passes these models to a method, and finally returns a response based on the method’s result.1 This helper function could have the following signature:

def handle_request(
    request: HttpRequest,
    handler_method: Callable,
    body_model: Optional[Type[BaseModel]],
    route_params_model: Optional[Type[BaseModel]],
    query_params_model: Optional[Type[BaseModel]],
    output_model: Type[BaseModel]
) -> HttpResponse: ...

We pass a request object and a handler method together with the classes that shall be used for parsing2. The handler method will receive instances of these classes. Unfortunately, nothing prevents us from completely misusing this function (except for runtime errors). We can have a handler method that does not accept or return any of the models we passed and the linter wouldn’t complain.

Let’s assume we always have a body, route params, and query params. We can use generics to ensure that the input and output argument types of the handler method match the passed models.

Route = TypeVar("Route", bound=BaseModel)
Query = TypeVar("Query", bound=BaseModel)
Body = TypeVar("Body", bound=BaseModel)
Output = TypeVar("Output", bound=BaseModel)

def handle_request(
    req: HttpRequest,
    handler_method: Callable[[Body, Route, Query], Output]
    *,
    body_model: Type[Body],
    route_params_model: Type[Route],
    query_params_model: Type[Query],
    output_model: Type[Output],
) -> HttpResponse: ...

Additionally, we force the models to be passed as keyword arguments, so the user doesn’t confuse them.

To allow requests without body, route or query params, we cannot simply declare them optional, because it would break our newly gained type safety. However, we can list all possible combination with Python’s override decorator. Each argument model can be either there or not, but we expect at least one3, so we need 231=72^3 - 1 = 7 overrides for our use case.

@override
def handle_request(
    req: HttpRequest,
    handler_method: Callable[[Body], Output]
    *,
    body_model: Type[Body],
    output_model: Type[Output],
) -> HttpResponse: ...

@override
def handle_request(
    req: HttpRequest,
    handler_method: Callable[[Body, Query], Output]
    *,
    body_model: Type[Body],
    query_params_model: Type[Query],
    output_model: Type[Output],
) -> HttpResponse: ...

Without ignoring the linter, it’s no longer possible to pass the wrong combination of handler method and models.

A problem of this approach is combinatorial explosion. If we decided to allow the handler method to optionally return an HttpResponse right away, we’d need to double the overrides with stuff like this:

@override
def handle_request(
    req: HttpRequest,
    handler_method: Callable[[Body], HttpResponse]
    *,
    body_model: Type[Body],
) -> HttpResponse: ...

  1. Whether having such a helper function is a good idea in the first place, is a question I won’t discuss here. We could instead inspect the handler method’s signature and not pass any model classes at all.↩︎

  2. The BaseModel could be Pydantic’s, for example, or anything that ensures it can be used for (de)serialization.↩︎

  3. If there aren’t any arguments, we can just call the handler method directly.↩︎