Python type hints are a feature that allows you to specify the expected data types of variables, arguments, as well as return values in your code. Python decorators, on the other hand, allow one to modify or extend the behavior of a function, method or class without changing its actual code. We have discussed Python decorators extensively in our previous blog posts:
Making Your Life Easier with Python Decorators
Python Decorators: Overcoming Common Misconceptions
In this blog post, we explore how to effectively integrate these two concepts by leveraging the new features that the latest Python versions provide. In the first part of this blog series we focus on type hinting for function decorators, while the next part will cover class decorators. All the examples have been tested with Python 3.13 and mypy 1.13.0.
The benefits of type hints
Python is, for all intents and purposes, a dynamic and duck-typed language, meaning it doesn’t enforce strict type rules at compile time but checks types at runtime. Duck typing specifically means that an object’s type is determined by its behavior—namely, the methods and attributes it supports—rather than its explicit class. As the saying goes, “If it looks like a duck and quacks like a duck, it’s a duck.”
This flexibility makes Python easy to work with, which is why many people favor the language, especially in smaller projects. However, when building some large system, Python code can quickly become hard to follow and debug without the strict requirements of a statically typed language. This is where type hints come in.
Introduced in PEP 484, type hints help improve code readability, make it easier to catch bugs early, and assist with tools like IDEs, linters, and static type checkers, such as mypy. Type hints are not enforced at runtime but can be checked using these tools to ensure that the code conforms to the expected types.
A simple example of Python type hints:
def calculate_total(price: float, tax_rate: float) -> float: return price * (1 + tax_rate)
The types of the arguments price
and tax_rate
are specified after colon ( : ). The type of the function’s return value is specified after the arrow ( ->). In this case, all the types used are floats.
Type hints also serve a self-documenting purpose. Below is a validation function that validates a set of product prices. It takes the prices as a dictionary and ensures that each product has a positive price. Without type hints, we would have to infer the structure of the incoming data from the function code.
class ValidationError(Exception): pass def validate_prices(prices): for item_id, price in prices.items(): if price < 0: raise ValidationError(f"Invalid price for {item_id}: Price cannot be negative.") With type hints, the structure of prices is immediately clear to the reader: def validate_prices(prices: dict[str, int | float]) -> None: for item_id, price in prices.items(): if price <= 0: raise ValidationError(f"Invalid price for {item_id}: Price cannot be negative.")
In addition to indicating that the dictionary keys are strings, the type hint documents that the dictionary values can be either integers or floats.
Next, let’s take a look at how type hints help to catch bugs early on, by using the following function as an example.
def register_user(user): username = user["username"] email = user["email"] age = user["age"] # Register the user here
With this function, several types of runtime errors can occur, such as type mismatches, missing required fields or unexpected data structures. Let’s fix the issue with type hints:
from typing import TypedDict class User(TypedDict): username: str email: str age: int def register_user(user: User) -> None: username = user["username"] email = user["email"] age = user["age"] # Register the user here user = User( username="mickey28", email="mickey.mouse@gmail.com", age=95 ) register_user(user)
Now, if we are using the mypy in our project, it flags errors like the ones mentioned above. By running mypy as part of the CI/CD pipeline, we ensure that such bugs don’t make it to production. And of course, the code now clearly documents the expected structure of the user
.
A generic approach
Type hints also support the use of generics, which allow you to write functions and classes that can handle multiple types. Generics are especially useful when you want your code to work with any type without sacrificing type safety.
Here is a simple example of two functions. The first function is specifically type hinted to accept only a list of integers and return an integer. The second function is generic, allowing a list of elements of any arbitrary type and expecting that it will return an object of that same type.
# Non-generic function def get_first_int(elements: list[int]) -> int: return elements[0] # Generic function def get_first[T](elements: list[T]) -> T: return elements[0] print(get_first_int([1, 2, 3])) # Works print(get_first([1, 2, 3])) # Works print(get_first_int(["a", "b", "c"])) # mypy error print(get_first(["a", "b", "c"])) # Works
T
in the example above is a type variable (TypeVar
) representing an unknown type. We could also use any other variable name, such as U
, or MyType
, but T
is commonly used by convention when there’s only a single unknown type involved.
Generics are very useful for example with utility functions and data structures. A more complex example that uses the Callable
type hint to represent any function (or other callable object) with a specific type signature:
from collections.abc import Callable def filter_map[T, U](elements: list[T], condition: Callable[[T], bool], transform: Callable[[T], U]) -> list[U]: result: list[U] = [] for element in elements: if condition(element): result.append(transform(element)) return result # Example usage numbers = [1, 2, 3, 4, 5, 6] even_doubled = filter_map(numbers, lambda x: x % 2 == 0, lambda x: x * 2) print(even_doubled) # Output: [4, 8, 12]
The function above transforms a list of objects into another list of (potentially) different types based on a provided transformation function. The transformation is applied only to those objects that meet the criteria specified by the condition function. Other objects from the list are discarded.
When used with mypy, type hints ensure that the provided function arguments, condition
and transform
, meet their expected type requirements. The generic condition
function takes an object of arbitrary type T
as an argument and returns a boolean, while transform
takes an object of same type T
and returns an object of some other type, denoted here with U
.
When using Callable
, we specify the types of all parameters inside square brackets. Since both condition
and transform
accept only a single parameter of type T
, we denote this with[T]
.
What about decorators?
Decorators are functions that take another function as an argument, and return a new function. They are essentially wrappers that can add functionality to an existing function, method, or class, in a clean and reusable way. Decorators are especially useful for cross-cutting concerns like logging, access control and validation. A simple example of a Python decorator that measures a function execution time:
import time def measure_execution_time(func): def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) execution_time = time.time() - start_time print(f"{func.__name__} executed in {execution_time:.4f} s") return result return wrapper @measure_execution_time def slow_function(): time.sleep(1) slow_function()
The code above would output something like this:
slow_function executed in 1.0002 seconds
Now that we know that typing can be quite useful, how do they work together with decorators? Let’s use the previous decorator as an example.
import time from collections.abc import Callable from functools import wraps def measure_execution_time[**P, R](func: Callable[P, R]) -> Callable[P, R]: # Preserve function metadata with wraps @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: start_time = time.time() result = func(*args, **kwargs) execution_time = time.time() - start_time print(f"{func.__name__} executed in {execution_time:.4f} s") return result return wrapper @measure_execution_time def slow_function() -> None: time.sleep(1)
Here, the notation **P
might be confusing. P
is a parameter specification (ParamSpec
), and the **P
syntax tells Python that P
captures the types of all the function’s parameters, including both positional and keyword arguments.
Because P
automatically represents the complete parameter list of func
, brackets around it are unnecessary when used with Callable
. R
, on the other hand, is just a normal generic type variable that represents the return type of func
.
Parameter specification syntax enables us to type functions that call other functions with the same arbitrary parameters, while preserving their signature in a type safe way. Now we can refer to the positional argument types of both wrapper
and func
, by P.args
, and to the keyword argument types by P.kwargs
.
Passing arguments
A very common use case with decorators is to pass some arguments to the wrapped function, such as context objects. Let’s take a look at the example below where a decorator passes a UserContext
object to any function automatically, so that we do not have to pass it ourselves every time we call the function. The example assumes that we have somewhere a get_user_context
function that returns an object of the following type:
from typing import TypedDict class UserContext(TypedDict): user_id: int username: str role: UserRole # This is just some enum
Here is the actual decorator:
from functools import wraps def add_user_context(func): @wraps(func) def wrapper(*args, **kwargs): return func(get_user_context(), *args, **kwargs) return wrapper
And some example usage:
@add_user_context def greet_user(user_context): return f"Hello, User {user_context['username']}. Your role is {user_context['role']}." @add_user_context def process_order(user_context, quantity): return f"Processing order {order_id} of quantity {quantity} for user {user_context['user_id']}." print(greet_user()) print(process_order(10, 5)) print(process_order(order_id=10, quantity=5))
Now we could assume this can be typed similarly as the previous decorator example:
from collections.abc import Callable from functools import wraps def add_user_context[**P, R](func: Callable[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
If we now try to run the code with the previously introduced decorated functions, we get the following error:
error: Argument 1 has incompatible type "UserContext"; expected "P.args" [arg-type]
This is because in our first return
statement we passed the unexpected UserContext
object before the original *args
. The type definition of func
argument only expects P
, which captures the types of *args
and **kwargs
. We have to edit Callable[P, R]
, to take UserContext
into account:
from collections.abc import Callable from functools import wraps from typing import Concatenate def add_user_context[**P, R](func: Callable[Concatenate[UserContext, P], R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
Concatenate
can be used in conjunction with Callable and ParamSpec
to type annotate a decorator which adds, removes, or transforms parameters of another function. In this case, the add_user_context
decorator expects a callable argument func
that takes an UserContext
as its first argument, as well as a list of some other parameters P
, and returns a value of type R
. The decorator then returns a new callable that accepts only the parameters P (without UserContext
) and returns type R
. Now the example is type safe and passes the type checker.
The solution can be further clarified by using a type alias for the type of the
argument.func
from collections.abc import Callable from functools import wraps from typing import Concatenate type CallableExpectingUserContext[**P, R] = Callable[Concatenate[UserContext, P], R] def add_user_context[**P, R](func: CallableExpectingUserContext[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
The type alias CallableWithUserContext
documents the purpose of the somewhat complex Concatenate
construction and shortens the type definition of the add_user_context
function.
Summary
In this blog post, we discussed the benefits of Python type hinting and showed how it can be neatly added to function decorators, by utilizing the features that the latest Python versions offer. In the next part, we focus on class decorators, and some challenges that still remain with type hinting and decorators.
The code examples can be found here.
This article was written by Buutti’s CTO Miikka Salmi with Buutti’s consultant Casper van Mourik.