Fighting with type hints – overloaded decorator

How to properly type-annotate the following decorator?

def pass_thru_none(func):
    def wrapper(value):
        if value is None:
            return None
        return func(value)

    return wrapper

The solution is not simple at all. Looking for it, we will explore many advanced Python features:

Example problem

Consider that we have a Python applications that reads data from a database and returns them to a JSON API.

We have classes for both the database models and API schemas. In practice, the classes could be SQLAlchemy and pydantic models, respectively, but for the sake of simplicity, I will use dataclasses in examples.

import datetime as dt
from dataclasses import dataclass
from typing import Optional


def format_datetime(value: dt.datetime) -> str:
    return value.isoformat()


@dataclass
class UserModel:

    name: str
    create_date: dt.datetime
    login_date: Optional[dt.datetime]


@dataclass
class UserSchema:

    name: str
    createDate: str
    loginDate: Optional[str]

    @staticmethod
    def from_model(model: UserModel) -> "UserSchema":
        return UserSchema(
            name=model.name,
            createDate=format_datetime(model.create_date),
            loginDate=format_datetime(model.login_date)
            if model.login_date is not None
            else None,
        )

In the example, UserModel could be a database model and UserSchema would be a schema for a JSON API. UserSchema.from_model() can construct the latter from the former, renaming field names to camelCase and converting dates to strings.

Handling None values

The example has one detail that does not look pretty – the ternary operator needed to take care of login_date, which is optional. Real-world example would have dozens of checks like this, so we may decide to improve the format_datetime() function to take care of that:

import datetime as dt
from typing import Optional


def format_datetime(value: Optional[dt.datetime]) -> Optional[str]:
    if value is None:
        return None
    return value.isoformat()

Unfortunately, Mypy complains about the improved function:

error: Argument "createDate" to "UserSchema" has incompatible type "Optional[str]"; expected "str" [arg-type]

The problem is that Mypy cannot infer that format_datetime(model.create_date) never returns None.

We have to use the @overload decorator to provide the typechecker with more details:

import datetime as dt
from typing import Optional, overload

@overload
def format_datetime(value: None) -> None:
    ...


@overload
def format_datetime(value: dt.datetime) -> str:
    ...


def format_datetime(value: Optional[dt.datetime]) -> Optional[str]:
    if value is None:
        return None
    return value.isoformat()

Reusable decorator

The format_datetime() function is an example of one conversion function, but a real application needs many functions like that.

It would be nice if all conversion functions passed None thru, without having to copy the logic (with two @overload variants) for each type. This is the motivation for the decorator from the beginning of this article:

import datetime as dt

def pass_thru_none(func):
    def wrapper(value):
        if value is None:
            return None
        return func(value)

    return wrapper


@pass_thru_none
def format_datetime(value: dt.datetime) -> str:
    return value.isoformat()

The decorator is straightforward. A few years ago, we would be done. But these days, the situation different. We want our type-safe code, and functions decorated by an untyped decorator would be untyped too.

To type the decorator, we will need at least two features from the typing module:

from typing import Callable, Optional, TypeVar

INPUT_T = TypeVar("INPUT_T")
OUTPUT_T = TypeVar("OUTPUT_T")


def pass_thru_none(
    func: Callable[[INPUT_T], OUTPUT_T]
) -> Callable[[Optional[INPUT_T]], Optional[OUTPUT_T]]:
    def wrapper(value: Optional[INPUT_T]) -> Optional[OUTPUT_T]:
        if value is None:
            return None
        return func(value)

    return wrapper

The code is getting ugly, yet it is not enough. We are getting an error that we saw earlier:

error: Argument "createDate" to "UserSchema" has incompatible type "Optional[str]"; expected "str" [arg-type]

Overloading the decorator

We need @overload for the decorator. But there is no syntax how to combine it with Callable.

Another complication seems unavoidable. Let's convert the decorator into a class with a __call__ method. The class has to be declared as Generic to preserve type information between __init__ and __call__.

from typing import Callable, Generic, Optional, TypeVar, overload

INPUT_T = TypeVar("INPUT_T")
OUTPUT_T = TypeVar("OUTPUT_T")


class pass_thru_none(Generic[INPUT_T, OUTPUT_T]):
    def __init__(self, func: Callable[[INPUT_T], OUTPUT_T]) -> None:
        self.func = func

    @overload
    def __call__(self, value: None) -> None:
        ...

    @overload
    def __call__(self, value: INPUT_T) -> OUTPUT_T:
        ...

    def __call__(self, value: Optional[INPUT_T]) -> Optional[OUTPUT_T]:
        if value is None:
            return None
        return self.func(value)

I know, this is getting crazy. All we wanted is to add type hints to a 6-line decorator, and we have this cryptic class. Yet we are not done yet. Mypy complains:

error: Overloaded function signatures 1 and 2 overlap with incompatible return types [misc]

Frankly, I expected all the problems so far, but this last resistance surprised me. Mypy docs explain the problem quite well – an unbound type variable can include None, so the two overloads may not be distinguishable.

A recommended fix is to swap order of the overloads. The swap alone does not help in our case, but when we relax one overload, the following example finally works:

from typing import Callable, Generic, Optional, TypeVar, overload

INPUT_T = TypeVar("INPUT_T")
OUTPUT_T = TypeVar("OUTPUT_T")


class pass_thru_none(Generic[INPUT_T, OUTPUT_T]):
    def __init__(self, func: Callable[[INPUT_T], OUTPUT_T]) -> None:
        self.func = func

    @overload
    def __call__(self, value: INPUT_T) -> OUTPUT_T:
        ...

    @overload
    def __call__(self, value: None) -> Optional[OUTPUT_T]:
        ...

    def __call__(self, value: Optional[INPUT_T]) -> Optional[OUTPUT_T]:
        if value is None:
            return None
        return self.func(value)

Because of the second overload was relaxed, Mypy can no longer infer that decorated functions always pass thru None, but that's not needed in practice.

Is it all?

The decorator with proper type annotations has grown significantly, but we might not be done yet.

We may want to update_wrapper. But it is OK to have an pass_thru_none instance with attributes of some function?

We should also consider how to use the decorator with class methods. The wrapper could accept variable *args, including self or cls at the first position. In such case, ParamSpec might be needed.

But we got too far already, so I will keep further complications for the reader.

Conclusion

Full code can be found in Gist.

Do I recommend the monster? No. Maybe, the decorator wasn't such a good idea. On the other hand, I can imagine similar code hidden deep in a framework, where proper type hinting is more important that having the most beautiful code.

The example shows how type hints in Python force us to a different code style. Some people could argue that this is not “Pythonic”. But it is a nice showcase of advanced tools from the typing module, so it was worth being nerd sniped by it :)