Accurately static typing decorators in Python is an icky business. The wrapper function obfuscates type information required to statically determine the types of the parameters and the return values of the wrapped function.
Let’s write a decorator that registers the decorated functions in a global dictionary during function definition time. Here’s how I used to annotate it:
# src.py
# Import 'Callable' from 'typing' module in < Py3.9.
from collections.abc import Callable
from functools import wraps
from typing import Any, TypeVar
R = TypeVar("R")
funcs = {}
def register(func: Callable[..., R]) -> Callable[..., R]:
"""Register any function at definition time in
the 'funcs' dict."""
# Registers the function during function defition time.
funcs[func.__name__] = func
@wraps(func)
def inner(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return inner
@register
def hello(name: str) -> str:
return f"Hello {name}!"
The functools.wraps decorator makes sure that the identity and the docstring of the
wrapped function don’t get gobbled up by the decorator. This is syntactically correct and if
you run Mypy against the code snippet, it’ll happily tell you that everything’s alright.
However, this doesn’t exactly do anything. If you call the hello function with the wrong
type of parameter, Mypy won’t be able to detect the mistake statically. Notice this:
...
hello(1) # Mypy doesn't complain about it all
All this for nothing!
PEP-6121 proposed ParamSpec and Concatenate in the typing module to address this
issue. Later on, these were introduced in Python 3.10. The former is required to precisely
add type hints to any decorator while the latter is needed to type annotate decorators that
change wrapped functions’ signatures.
If you’re not on Python 3.10+, you can import
ParamSpecandConcatenatefrom thetyping_extensionsmodule. The package gets automatically installed with Mypy.
Use ParamSpec to type decorators
I’ll take advantage of both ParamSpec and TypeVar to annotate the register decorator
that we’ve seen earlier:
# src.py
# Import 'Callable' from 'typing' module in < Py3.9.
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
funcs = {}
def register(func: Callable[P, R]) -> Callable[P, R]:
funcs[func.__name__] = func
@wraps(func)
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return inner
@register
def hello(name: str) -> str:
return f"Hello {name}!"
# Try calling the function with the wrong param type.
print(hello(1)) # Mypy will complain here!
Above, I’ve used ParamSpec to annotate the type of the wrapped function’s input parameters
and TypeVar to annotate its return value. Underneath, ParamSpec is a type variable
similar to TypeVar but with a trick under its sleeve; it can relay type information to a
decorator’s inner callable.
Notice the annotations of the inner function inside register. Here, P.args and
P.kwargs are transferring the type information from the wrapped func to the inner
function. This makes sure that static type checkers like Mypy can now precisely scream at
you whenever you call the decorated functions with the wrong type of parameters.
Use Concatenate to type decorators that change the wrapped functions’ signatures
There’s another type of decorator that changes the signature of the wrapped function by
adding or removing parameters during runtime. Annotating these can be tricky; as the magic
happens mostly during runtime. The Concatenate type allows us to communicate this behavior
with the type checker.
Consider this inject_logger decorator, that adds a logger instance to the decorated
function. It sort of acts how Django injects the request instances into the view
functions. Here’s the typed version of that:
# src.py
import logging
# Import 'Callable' from 'typing' module in < Py3.9.
from collections.abc import Callable
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def inject_logger(
func: Callable[Concatenate[logging.Logger, P], R],
) -> Callable[P, R]:
# Runs this during function definition time only.
logger = logging.getLogger(func.__name__)
@wraps(func)
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return func(logger, *args, *kwargs)
return inner
@inject_logger
def hello(logger: logging.Logger, name: str) -> None:
logger.warning("Spooky action in distance...")
return f"Hello {name}!"
# Notice how you can call the hello function without
# inserting the first parameter. The decorator does
# that for you.
print(hello("world"))
This is a contrived example and a gratuitously complicated way to achieve a simple goal. Also, it’s not recommended to mutate function signatures like this in runtime. But it’s allowed and now Python gives you a way to statically type check the decorator and the decorated function.
The only thing that’s different from the previous section is the annotation of the func
parameter of the inject_logger. Notice how the Callable generic now contain
Concatenate[logging.Logger, P]. The first parameter of the Concatenate generic is the
injected parameter — logging.Logger in this case. Since the instance of logging.Logger
gets dynamically injected, an additional paradigm Concatenate is necessary to communicate
that with the type checker.
If you’d defined hello with the wrong types, the type checker would’ve complained.
...
@inject_logger
def hello(logger: int, name: str) -> str:
logger.warning("Spooky action in distance...")
return f"Hello {name}!"
Above, I’ve changed the type of the logger parameter from logging.Logger to int. The
type checker will now dutifully chastise us for our transgressions.
Unfortunately, as of writing this post, Mypy doesn’t understand Concatenate but
Microsoft’s Pyright2 does. You can pip install Pyright and test out the above snippet as
follows:
pyright src.py
This will return:
...
Parameter 1: type "Logger" cannot be assigned to type "int"
"Logger" is incompatible with "int" (reportGeneralTypeIssues)
./src.py:83:12 - error: Cannot access member "warning" for type "int"
Recent posts
- Revisiting interface segregation in Go
- Avoiding collisions in Go context keys
- Organizing Go tests
- Subtest grouping in Go
- Let the domain guide your application structure
- Test state, not interactions
- Early return and goroutine leak
- Lifecycle management in Go tests
- Gateway pattern for external service calls
- Flags for discoverable test config in Go