ParamSpec and Concatenate
Study this decorator and predict what mypy infers:
from typing import Callable, TypeVar
R = TypeVar("R")
def log_call(func: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args, **kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
result = greet(42, invalid_kwarg="boom")
mypy reports no errors on the last line. greet originally requires (str, str), but after decoration its signature is (*args: Any, **kwargs: Any) -> str. The Callable[..., R] pattern preserves the return type but destroys the parameter types. You can pass anything. This is the decorator typing problem, and it plagued Python for years.
ParamSpec (PEP 612, Python 3.10) solves it completely.
What You Will Learn
- Why
Callable[..., R]destroys parameter information - How
ParamSpeccaptures and preserves full callable signatures - How
Concatenateprepends parameters to existing signatures - Typing common decorator patterns: retry, logging, timing, authentication
- Real-world application: FastAPI dependency injection, middleware typing
- Edge cases and limitations of ParamSpec
Prerequisites
- TypeVar and Generic from Lesson 1
- Decorator fundamentals (writing decorators with
functools.wraps) - Understanding of
*argsand**kwargs - Basic experience with
Callabletype hints
Part 1 -- The Decorator Typing Problem
Why Callable[..., R] Fails
The core issue is information loss. Consider three approaches to typing a decorator:
from typing import Callable, TypeVar, Any
R = TypeVar("R")
# Approach 1: Callable[..., R] -- loses parameter types
def approach_1(func: Callable[..., R]) -> Callable[..., R]:
def wrapper(*args: Any, **kwargs: Any) -> R:
return func(*args, **kwargs)
return wrapper
# Approach 2: Specific signature -- not reusable
def approach_2(func: Callable[[str, int], bool]) -> Callable[[str, int], bool]:
def wrapper(name: str, age: int) -> bool:
return func(name, age)
return wrapper
# Approach 3: TypeVar for callable -- does not work
F = TypeVar("F", bound=Callable[..., Any])
def approach_3(func: F) -> F:
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)
return wrapper # type: ignore -- wrapper is not F
Approach 1 loses parameters. Approach 2 is not generic. Approach 3 looks promising but fails because wrapper is a new function that does not satisfy the original type F -- you need # type: ignore.
Part 2 -- ParamSpec Fundamentals
Basic ParamSpec Usage
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def log_call(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
greet("Alice") # OK
greet("Alice", greeting="Hi") # OK
greet(42) # ERROR: int is not str
greet(invalid="boom") # ERROR: unexpected keyword argument
ParamSpec("P") captures the entire parameter specification of a callable -- positional args, keyword args, defaults, and all. When you annotate *args: P.args and **kwargs: P.kwargs, the type checker knows the wrapper has the exact same signature as the original function.
How ParamSpec Differs from TypeVar
| Feature | TypeVar | ParamSpec |
|---|---|---|
| Represents | A single type | An entire parameter list |
| Used in | Generic[T], function params/returns | Callable[P, R] |
| Access | T directly | P.args and P.kwargs |
| Constraints | bound=, constrained types | None (captures any signature) |
P.args and P.kwargs can only be used together in *args: P.args, **kwargs: P.kwargs. You cannot use P.args alone, split them, or assign them to variables. They are not regular types -- they are syntactic markers that tell the type checker "this wrapper forwards all arguments."
The functools.wraps Connection
Always use @functools.wraps(func) with ParamSpec decorators. While ParamSpec handles type information, functools.wraps preserves runtime metadata (__name__, __doc__, __module__, __qualname__, __annotations__):
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def timer(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func) # Preserves runtime metadata
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
import time
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def compute(data: list[int], threshold: float = 0.5) -> list[int]:
"""Filter data above threshold."""
return [x for x in data if x > threshold]
print(compute.__name__) # "compute" (not "wrapper")
print(compute.__doc__) # "Filter data above threshold."
Part 3 -- Common Decorator Patterns with ParamSpec
Pattern: Retry Decorator
from typing import Callable, ParamSpec, TypeVar
import functools
import time
P = ParamSpec("P")
R = TypeVar("R")
def retry(
max_attempts: int = 3,
delay: float = 1.0,
exceptions: tuple[type[Exception], ...] = (Exception,),
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Retry a function on failure with exponential backoff."""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exception: Exception | None = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
time.sleep(delay * (2 ** attempt))
raise last_exception # type: ignore[misc]
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url: str, timeout: int = 30) -> dict[str, object]:
"""Fetch data from a URL."""
import urllib.request
# Simulated implementation
return {"status": "ok"}
# Full type safety preserved:
fetch_data("https://api.example.com") # OK
fetch_data("https://api.example.com", timeout=10) # OK
fetch_data(42) # ERROR: int is not str
fetch_data("url", invalid=True) # ERROR: unexpected kwarg
Pattern: Caching Decorator with Custom Key
from typing import Callable, ParamSpec, TypeVar, Hashable
import functools
P = ParamSpec("P")
R = TypeVar("R")
def memoize(func: Callable[P, R]) -> Callable[P, R]:
"""Simple memoization -- assumes all args are hashable."""
cache: dict[Hashable, R] = {}
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Build a hashable key from args and kwargs
key: Hashable = (args, tuple(sorted(kwargs.items())))
if key not in cache:
cache[key] = func(*args, **kwargs)
return cache[key]
return wrapper
@memoize
def fibonacci(n: int) -> int:
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # 55
# fibonacci("hello") # ERROR: str is not int
Pattern: Authentication Guard
from typing import Callable, ParamSpec, TypeVar
from dataclasses import dataclass
import functools
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class AuthContext:
user_id: str
roles: list[str]
def require_role(role: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator that checks user role before executing the function."""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# In real code, extract auth from request context
auth = AuthContext(user_id="user-1", roles=["admin"])
if role not in auth.roles:
raise PermissionError(f"Role '{role}' required")
return func(*args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user_id: str, soft: bool = True) -> bool:
print(f"Deleting user {user_id} (soft={soft})")
return True
delete_user("user-42") # OK
delete_user("user-42", soft=False) # OK
delete_user(123) # ERROR: int is not str
Part 4 -- Concatenate: Adding Parameters
Sometimes a decorator adds parameters to a function's signature. Concatenate handles this:
from typing import Callable, ParamSpec, TypeVar, Concatenate
import functools
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class Request:
user_id: str
path: str
def inject_request(
func: Callable[Concatenate[Request, P], R]
) -> Callable[P, R]:
"""Removes the Request parameter -- it gets injected automatically."""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
request = Request(user_id="current-user", path="/api/resource")
return func(request, *args, **kwargs)
return wrapper
@inject_request
def get_profile(request: Request, include_email: bool = False) -> dict[str, str]:
profile = {"user_id": request.user_id}
if include_email:
profile["email"] = f"{request.user_id}@example.com"
return profile
# The decorated function no longer needs Request as a parameter:
get_profile() # OK -- Request is injected
get_profile(include_email=True) # OK
get_profile(Request(...)) # ERROR -- Request is no longer a parameter
Concatenate[Request, P] means "a callable whose first parameter is Request, followed by whatever P captures." The decorator then returns Callable[P, R] -- the same function minus the Request parameter.
How Concatenate Works
Concatenate for Adding Parameters
The reverse pattern -- adding a parameter:
from typing import Callable, ParamSpec, TypeVar, Concatenate
import functools
P = ParamSpec("P")
R = TypeVar("R")
def with_debug_flag(
func: Callable[P, R]
) -> Callable[Concatenate[bool, P], R]:
"""Adds a 'debug' flag as the first positional argument."""
@functools.wraps(func)
def wrapper(debug: bool, /, *args: P.args, **kwargs: P.kwargs) -> R:
if debug:
print(f"DEBUG: calling {func.__name__} with {args}, {kwargs}")
return func(*args, **kwargs)
return wrapper
@with_debug_flag
def process(data: str, count: int = 1) -> str:
return data * count
process(True, "hello", count=3) # OK: debug=True, data="hello", count=3
process(False, "world") # OK: debug=False, data="world"
process("hello") # ERROR: first arg must be bool
Concatenate only works with positional parameters. You cannot use it to add keyword-only arguments. If you need to inject keyword-only parameters, you must use a different approach (such as **kwargs manipulation or a Protocol with __call__).
Multiple Concatenated Parameters
from typing import Callable, ParamSpec, TypeVar, Concatenate
import functools
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class DBSession:
url: str
@dataclass
class AuthToken:
token: str
def inject_deps(
func: Callable[Concatenate[DBSession, AuthToken, P], R]
) -> Callable[P, R]:
"""Inject both a database session and auth token."""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
db = DBSession(url="postgresql://localhost/app")
auth = AuthToken(token="secret-token-123")
return func(db, auth, *args, **kwargs)
return wrapper
@inject_deps
def create_order(
db: DBSession,
auth: AuthToken,
product_id: str,
quantity: int = 1,
) -> dict[str, object]:
return {
"product": product_id,
"quantity": quantity,
"user": auth.token,
"db": db.url,
}
# After decoration, only product_id and quantity remain:
create_order("PROD-001") # OK
create_order("PROD-001", quantity=5) # OK
create_order() # ERROR: missing product_id
Part 5 -- Async Decorator Typing
ParamSpec works with async functions, but requires careful handling:
from typing import Callable, ParamSpec, TypeVar, Awaitable
import functools
import asyncio
P = ParamSpec("P")
R = TypeVar("R")
def async_retry(
max_attempts: int = 3,
) -> Callable[
[Callable[P, Awaitable[R]]],
Callable[P, Awaitable[R]],
]:
"""Retry an async function on failure."""
def decorator(
func: Callable[P, Awaitable[R]]
) -> Callable[P, Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
last_exc: Exception | None = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exc = e
await asyncio.sleep(0.1 * (2 ** attempt))
raise last_exc # type: ignore[misc]
return wrapper # type: ignore[return-value]
return decorator
@async_retry(max_attempts=3)
async def fetch_user(user_id: str) -> dict[str, str]:
# Simulated async HTTP call
return {"id": user_id, "name": "Alice"}
# Type-safe:
# await fetch_user("user-1") # OK
# await fetch_user(42) # ERROR: int is not str
The Callable[P, Awaitable[R]] pattern distinguishes async functions from sync ones. For a decorator that works with both sync and async functions, you need @overload (covered in Lesson 5) or a union approach.
Typing a Decorator That Works with Both Sync and Async
from typing import Callable, ParamSpec, TypeVar, Awaitable, overload
import functools
import asyncio
P = ParamSpec("P")
R = TypeVar("R")
@overload
def timed(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: ...
@overload
def timed(func: Callable[P, R]) -> Callable[P, R]: ...
def timed(func: Callable[P, R]) -> Callable[P, R]:
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
import time
start = time.perf_counter()
result = await func(*args, **kwargs) # type: ignore[misc]
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return async_wrapper # type: ignore[return-value]
else:
@functools.wraps(func)
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
import time
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__}: {time.perf_counter() - start:.4f}s")
return result
return sync_wrapper
Part 6 -- Real-World Applications
FastAPI-Style Dependency Injection
FastAPI's Depends system uses the return type of dependency functions to inject typed parameters. Here is a simplified version showing how ParamSpec enables this pattern:
from typing import Callable, ParamSpec, TypeVar, Concatenate, Any
from dataclasses import dataclass
import functools
P = ParamSpec("P")
R = TypeVar("R")
@dataclass
class DatabaseSession:
connection_string: str
@dataclass
class CurrentUser:
id: str
email: str
roles: list[str]
def get_db() -> DatabaseSession:
return DatabaseSession("postgresql://localhost/app")
def get_current_user() -> CurrentUser:
def inject(
db_factory: Callable[[], DatabaseSession] = get_db,
user_factory: Callable[[], CurrentUser] = get_current_user,
) -> Callable[
[Callable[Concatenate[DatabaseSession, CurrentUser, P], R]],
Callable[P, R],
]:
def decorator(
func: Callable[Concatenate[DatabaseSession, CurrentUser, P], R]
) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
db = db_factory()
user = user_factory()
return func(db, user, *args, **kwargs)
return wrapper
return decorator
@inject()
def list_courses(
db: DatabaseSession,
user: CurrentUser,
category: str = "all",
limit: int = 10,
) -> list[dict[str, Any]]:
print(f"User {user.email} querying {db.connection_string}")
return [{"title": "Python Advanced", "category": category}]
# After injection, only category and limit remain:
courses = list_courses() # OK
courses = list_courses(category="python") # OK
courses = list_courses(category="ai", limit=5) # OK
courses = list_courses(db=DatabaseSession("x")) # ERROR: unexpected kwarg
Logging Decorator with Configurable Output
from typing import Callable, ParamSpec, TypeVar
import functools
import logging
P = ParamSpec("P")
R = TypeVar("R")
def log_calls(
logger: logging.Logger | None = None,
level: int = logging.INFO,
include_result: bool = False,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Production-grade logging decorator with full type preservation."""
def decorator(func: Callable[P, R]) -> Callable[P, R]:
_logger = logger or logging.getLogger(func.__module__)
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
_logger.log(level, "Calling %s", func.__qualname__)
try:
result = func(*args, **kwargs)
if include_result:
_logger.log(level, "%s returned %r", func.__qualname__, result)
return result
except Exception:
_logger.exception("Exception in %s", func.__qualname__)
raise
return wrapper
return decorator
@log_calls(level=logging.DEBUG, include_result=True)
def calculate_price(base: float, tax_rate: float, discount: float = 0.0) -> float:
return base * (1 + tax_rate) * (1 - discount)
# Signature fully preserved:
calculate_price(100.0, 0.2) # OK
calculate_price(100.0, 0.2, discount=0.1) # OK
calculate_price("not a float", 0.2) # ERROR: str is not float
Part 7 -- Edge Cases and Limitations
Limitation: ParamSpec Cannot Be Decomposed
You cannot inspect individual parameters from a ParamSpec:
from typing import ParamSpec
P = ParamSpec("P")
# You CANNOT do:
# first_param = P.args[0] # Not valid
# num_params = len(P.args) # Not valid
ParamSpec is opaque -- you can forward it, concatenate to it, but not decompose it.
Limitation: No ParamSpec Constraints
Unlike TypeVar, ParamSpec does not support bound or constraints:
from typing import ParamSpec
# These do NOT exist:
# P = ParamSpec("P", bound=SomeCallable)
# P = ParamSpec("P", [int, str], [float, bool])
Gotcha: Decorator Factories Need Extra Care
A decorator factory (decorator that takes arguments) has an extra layer of nesting. Getting the types right requires careful placement of ParamSpec:
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
# CORRECT: ParamSpec in the inner decorator
def rate_limit(calls_per_second: float) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Rate limiting logic here
return func(*args, **kwargs)
return wrapper
return decorator
# The return type of rate_limit is:
# Callable[[Callable[P, R]], Callable[P, R]]
# This reads: "takes a function with params P returning R, returns same signature"
Gotcha: ParamSpec with Methods
When decorating methods, self or cls is captured by ParamSpec:
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def log_method(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
class Calculator:
@log_method # ParamSpec captures (self, x: float, y: float)
def add(self, x: float, y: float) -> float:
return x + y
calc = Calculator()
calc.add(1.0, 2.0) # OK -- self is handled transparently
This works because self is just another parameter that ParamSpec captures. No special handling needed.
Compatibility: Python Version Requirements
# Python 3.10+: ParamSpec and Concatenate in typing
from typing import ParamSpec, Concatenate
# Python 3.8-3.9: Use typing_extensions
from typing_extensions import ParamSpec, Concatenate
If your library supports Python 3.8+, always import from typing_extensions. It provides backports of newer typing features and is a lightweight, zero-dependency package.
Key Takeaways
Callable[..., R]preserves return types but destroys parameter types -- avoid it for decoratorsParamSpec("P")captures the entire parameter specification of a callable- Use
*args: P.args, **kwargs: P.kwargsin wrappers to forward parameters with full type safety Concatenate[X, P]prepends parameterXto the captured spec -- use for injecting or removing parameters- Always pair ParamSpec with
@functools.wraps(func)to preserve runtime metadata - Decorator factories return
Callable[[Callable[P, R]], Callable[P, R]] - For async decorators, use
Callable[P, Awaitable[R]]as the type - ParamSpec is opaque: you can forward and concatenate, but not inspect individual parameters
- Import from
typing_extensionsfor Python < 3.10 compatibility
Graded Practice Challenges
Level 1 -- Predict the Type Checker Output
Question 1: What does mypy infer for result?
from typing import Callable, ParamSpec, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def identity_decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return func(*args, **kwargs)
return wrapper
@identity_decorator
def add(x: int, y: int) -> int:
return x + y
result = add(1, 2)
Answer
result is inferred as int. The decorator preserves the full signature (x: int, y: int) -> int, so the return type flows through correctly. add(1, "2") would produce a type error.
Question 2: Does this code pass mypy?
from typing import Callable, ParamSpec, Concatenate, TypeVar
import functools
P = ParamSpec("P")
R = TypeVar("R")
def add_verbose(
func: Callable[P, R]
) -> Callable[Concatenate[bool, P], R]:
@functools.wraps(func)
def wrapper(verbose: bool, /, *args: P.args, **kwargs: P.kwargs) -> R:
if verbose:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@add_verbose
def greet(name: str) -> str:
return f"Hello, {name}"
greet(True, "Alice")
greet("Alice")
Answer
greet(True, "Alice") passes -- the decorated signature is (bool, str) -> str.
greet("Alice") fails -- after decoration, the first argument must be bool. mypy reports that the first positional argument should be bool, not str. The verbose parameter is now required.
Question 3: What is wrong with this code?
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def validate(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for arg in args:
if arg is None:
raise ValueError("None not allowed")
return func(*args, **kwargs)
return wrapper
Answer
Two issues:
-
Missing
@functools.wraps(func): The wrapper does not preserve__name__,__doc__, and other metadata from the original function. -
Iterating over
P.args: While this works at runtime (args is a tuple), the type checker treatsP.argsas opaque. Thefor arg in argsloop works at runtime but the type ofargwould beobject-- you lose specific argument type information. More importantly, you cannot meaningfully validate arguments this way because you do not know their expected types. This is a design smell -- validation should happen at a higher level (e.g., with Pydantic).
Level 2 -- Debug and Fix
This timing decorator is supposed to work but has type errors. Fix all issues:
from typing import Callable, ParamSpec, TypeVar
import time
P = ParamSpec("P")
R = TypeVar("R")
def timed(label: str) -> Callable[P, R]:
def decorator(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.time()
result = func(*args, **kwargs)
print(f"[{label}] {func.__name__}: {time.time() - start:.3f}s")
return result
return wrapper
return decorator
@timed("DB")
def query_users(role: str, limit: int = 100) -> list[str]:
return ["alice", "bob"]
Answer
The return type of timed is wrong. It returns Callable[P, R], but it should return a decorator -- a function that takes a callable and returns a callable.
Fix:
from typing import Callable, ParamSpec, TypeVar
import functools
import time
P = ParamSpec("P")
R = TypeVar("R")
def timed(label: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# Returns a decorator, not a direct callable
def decorator(func: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(func) # Also add wraps
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
start = time.time()
result = func(*args, **kwargs)
print(f"[{label}] {func.__name__}: {time.time() - start:.3f}s")
return result
return wrapper
return decorator
@timed("DB")
def query_users(role: str, limit: int = 100) -> list[str]:
return ["alice", "bob"]
Level 3 -- Design Challenge
Build a typed decorator @validate_args that validates function arguments using Pydantic-style type checking at runtime, while preserving the full function signature for static type checkers:
# Desired usage:
@validate_args
def create_user(name: str, age: int, email: str) -> dict[str, object]:
return {"name": name, "age": age, "email": email}
# mypy also flags "thirty" as wrong type
Requirements:
- Use ParamSpec to preserve the function signature
- Use
typing.get_type_hints()to extract parameter types at runtime - Validate each argument against its type hint using
isinstance - Raise
TypeErrorwith a clear message on mismatch - Preserve
@functools.wrapsmetadata
Hint
from typing import Callable, ParamSpec, TypeVar, get_type_hints
import functools
import inspect
P = ParamSpec("P")
R = TypeVar("R")
def validate_args(func: Callable[P, R]) -> Callable[P, R]:
hints = get_type_hints(func)
sig = inspect.signature(func)
params = list(sig.parameters.keys())
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Validate positional args
for i, (value, param_name) in enumerate(zip(args, params)):
if param_name in hints and param_name != "return":
expected = hints[param_name]
if not isinstance(value, expected):
raise TypeError(
f"Argument '{param_name}' expected {expected.__name__}, "
f"got {type(value).__name__}"
)
# Validate keyword args
for param_name, value in kwargs.items():
if param_name in hints:
expected = hints[param_name]
if not isinstance(value, expected):
raise TypeError(
f"Argument '{param_name}' expected {expected.__name__}, "
f"got {type(value).__name__}"
)
return func(*args, **kwargs)
return wrapper
Note: This simple version only handles concrete types. Handling Union, Optional, generics, and other complex types requires the approaches covered in Lesson 6 (Runtime Type Checking).
What's Next
In the next lesson, Advanced Generic Patterns, we explore Self types, TypeVarTuple for variadic generics, recursive types, and patterns for builder APIs and tensor shape typing.
