How to Type Hint a Decorator in Python

Decorators are a concept that can trip up new Python users. You may find this definition helpful: A decorator is a function that takes in another function and adds new functionality to it without modifying the original function.

Functions can be used just like any other data type in Python. A function can be passed to a function or returned from a function, just like a string or integer.

If you have jumped on the type-hinting bandwagon, you will probably want to add type hints to your decorators. That has been difficult until fairly recently.

Let’s see how to type hint a decorator!

Type Hinting a Decorator the Wrong Way

You might think that you can use a TypeVar to type hint a decorator. You will try that first.

Here’s an example:

from functools import wraps
from typing import Any, Callable, TypeVar


Generic_function = TypeVar("Generic_function", bound=Callable[..., Any])

def info(func: Generic_function) -> Generic_function:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        print('Function name: ' + func.__name__)
        print('Function docstring: ' + str(func.__doc__))
        result = func(*args, **kwargs)
        return result
    return wrapper

@info
def doubler(number: int) -> int:
    """Doubles the number passed to it"""
    return number * 2

print(doubler(4))

If you run mypy —strict info_decorator.py you will get the following output:

info_decorator.py:14: error: Incompatible return value type (got "_Wrapped[[VarArg(Any), KwArg(Any)], Any, [VarArg(Any), KwArg(Any)], Any]", expected "Generic_function")  [return-value]
Found 1 error in 1 file (checked 1 source file)

That’s a confusing error! Feel free to search for an answer.

The answers that you find will probably vary from just ignoring the function (i.e. not type hinting it at all) to using something called a ParamSpec.

Let’s try that next!

Using a ParamSpec for Type Hinting

The ParamSpec is a class in Python’s typing module. Here’s what the docstring says about ParamSpec:

class ParamSpec(object):
  """ Parameter specification variable.
  
  The preferred way to construct a parameter specification is via the
  dedicated syntax for generic functions, classes, and type aliases,
  where the use of '**' creates a parameter specification::
  
      type IntFunc[**P] = Callable[P, int]
  
  For compatibility with Python 3.11 and earlier, ParamSpec objects
  can also be created as follows::
  
      P = ParamSpec('P')
  
  Parameter specification variables exist primarily for the benefit of
  static type checkers.  They are used to forward the parameter types of
  one callable to another callable, a pattern commonly found in
  higher-order functions and decorators.  They are only valid when used
  in ``Concatenate``, or as the first argument to ``Callable``, or as
  parameters for user-defined Generics. See class Generic for more
  information on generic types.
  
  An example for annotating a decorator::
  
      def add_logging[**P, T](f: Callable[P, T]) -> Callable[P, T]:
          '''A type-safe decorator to add logging to a function.'''
          def inner(*args: P.args, **kwargs: P.kwargs) -> T:
              logging.info(f'{f.__name__} was called')
              return f(*args, **kwargs)
          return inner
  
      @add_logging
      def add_two(x: float, y: float) -> float:
          '''Add two numbers together.'''
          return x + y
  
  Parameter specification variables can be introspected. e.g.::
  
      >>> P = ParamSpec("P")
      >>> P.__name__
      'P'
  
  Note that only parameter specification variables defined in the global
  scope can be pickled.
   """

In short, you use a ParamSpec to construct a parameter specification for a generic function, class, or type alias.

To see what that means in code, you can update the previous decorator to look like this: 

from functools import wraps
from typing import Callable, ParamSpec, TypeVar


P = ParamSpec("P")
R = TypeVar("R")

def info(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print('Function name: ' + func.__name__)
        print('Function docstring: ' + str(func.__doc__))
        return func(*args, **kwargs)
    return wrapper

@info
def doubler(number: int) -> int:
    """Doubles the number passed to it"""
    return number * 2

print(doubler(4))

Here, you create a ParamSpec and a TypeVar. You tell the decorator that it takes in a Callable with a generic set of parameters (P), and you use TypeVar (R) to specify a generic return type.

If you run mypy on this updated code, it will pass! Good job!

What About PEP 695?

PEP 695 adds a new wrinkle to adding type hints to decorators by updating the parameter specification in Python in 3.12.

The main thrust of this PEP is to “simplify” the way you specify type parameters within a generic class, function, or type alias.

In a lot of ways, it does clean up the code as you no longer need to import ParamSpec of TypeVar when using this new syntax. Instead, it feels almost magical.

Here’s the updated code:

from functools import wraps
from typing import Callable


def info[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        print('Function name: ' + func.__name__)
        print('Function docstring: ' + str(func.__doc__))
        return func(*args, **kwargs)
    return wrapper

@info
def doubler(number: int) -> int:
    """Doubles the number passed to it"""
    return number * 2

print(doubler(4))

Notice that at the beginning of the function you have square brackets. That is basically declaring your ParamSpec implicitly. The “R” is again the return type. The rest of the code is the same as before.

When you run mypy against this version of the type hinted decorator, you will see that it passes happily.

Wrapping Up

Type hinting can still be a hairy subject, but the newer the Python version that you use, the better the type hinting capabilities are.

Of course, since Python itself doesn’t enforce type hinting, you can just skip all this too. But if your employer like type hinting, hopefully this article will help you out.

Related Reading