Skip to Content
Course content

316: ParamSpec and Callable Typing

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've been writing decorators in Python, you've probably hit a wall where your type hints just... give up. You wrap a function to add logging or timing, and suddenly your IDE treats the decorated function as if it takes any arguments and returns anything. It's frustrating because you've spent all this time typing your business logic, only to have a decorator wipe out all that helpful metadata.

The problem with generic decorators

Let's say I'm building a small utility to time how long certain database queries take. My first instinct is to write a simple decorator using Callable. Here is how I usually start, and where I usually mess up:

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

def time_execution(func: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

@time_execution
def fetch_user_data(user_id: int, include_details: bool = False) -> str:
    return f"Data for {user_id}"

At first glance, this looks fine. It runs perfectly. But here is the catch: if you try to call fetch_user_data(user_id="not-an-int"), your type checker won't complain. By using Callable[..., Any], I've essentially told Python, "This function now takes whatever it wants and returns whatever it feels like." I've erased the signature of fetch_user_data.

Bringing in ParamSpec to save our signatures

To fix this, we need a way to say: "Whatever parameters the original function had, the wrapper should have those exact same parameters." This is exactly why ParamSpec was introduced in Python 3.10.

I need two things here: a TypeVar to track the return type and a ParamSpec to track the arguments. I'll call the ParamSpec P and the return type R. This is a common convention in the community.

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

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

def time_execution(func: Callable[P, R]) -> Callable[P, R]:
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

Notice the magic happening in Callable[P, R]. By using P, I'm capturing the full parameter list (including names and default values) of the function being decorated. Then, by using *args: P.args and **kwargs: P.kwargs, I'm telling the type checker that the wrapper's arguments are identical to the original function's arguments.

Seeing it in action

Now, let's look at how this actually behaves when we use it. I'll define a couple of different functions to make sure the generic nature of our decorator is actually working.

@time_execution
def calculate_total(price: float, tax: float) -> float:
    return price + (price * tax)

@time_execution
def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

# The IDE now knows exactly what these want:
calculate_total(100.0, 0.05) # Correct
greet("Alice")               # Correct
# greet(123)                # Type checker error: Expected str, got int

By using ParamSpec, we've created a "transparent" decorator. The logic is injected, but the type identity of the original function remains intact. It's a small change in the code, but it's the difference between a codebase that feels guessed at and one that feels engineered.




📋 Practical Task

Exercise: Type-Safe Authorization Decorator

You are building a system where certain functions require a "user" object to be passed as the first argument for authorization purposes. You need to create a decorator called require_auth that logs the user's ID before executing the function, but it must preserve the type hints of the wrapped function so that other developers don't pass the wrong arguments.

Requirements:

  • Use ParamSpec and TypeVar to ensure the decorated function maintains its original signature.
  • The decorator should assume the first argument of the wrapped function is a User object (you can define a simple User dataclass with an id: int attribute).
  • The decorator should print "Authorizing user {user_id}..." before calling the actual function.
  • Implement a sample function update_profile(user: User, bio: str) -> bool and decorate it with require_auth.
  • Verify that calling update_profile with a non-string bio triggers a type-checker warning (or simply ensure the signature is correctly preserved).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.