65.9 Self, Unpack, ParamSpec, and Concatenate
Alright, let’s get into the weeds. We’ve covered the basics and the generics, but now we’re hitting the type system’s advanced maneuvers. These are the tools you pull out when you’re designing a deeply flexible API, a complex decorator, or when you’re trying to describe a pattern so dynamic that Any feels like a cop-out. They exist because the Python core team, bless their hearts, ran into these exact problems while trying to type-check their own code. Let’s meet the heavy hitters.
The Self Type: Because You’re Not That Special
You’ve written a class method that returns an instance of the class itself. Maybe it’s a fluent interface builder pattern, or a from_ constructor. Before Self, you’d have to do this nightmare:
class MyClass:
def return_itself(self) -> "MyClass":
return self
That works, until you subclass it. Then MySubclass.return_itself() is typed as returning a MyClass, which is… a lie. It returns a MySubclass. You could generify it with a TypeVar, but that’s a verbose pain for a ridiculously common pattern.
Enter Self. It means “an instance of the current class, whatever that may be, including in subclasses.”
from typing import Self
class Robot:
def __init__(self, name: str):
self.name = name
def with_new_name(self, new_name: str) -> Self:
# Returns an instance of the same type as `self`
return type(self)(new_name)
@classmethod
def from_config(cls, config: dict) -> Self:
# Returns an instance of `cls`
return cls(config["name"])
class AdvancedRobot(Robot):
has_jetpack: bool = True
# Type checkers now know this is an AdvancedRobot, not just a Robot
new_bot = AdvancedRobot("Bleep").with_new_name("Blorp")
reveal_type(new_bot) # Revealed type is "AdvancedRobot"
It’s simple, elegant, and fixes one of the most common class-based typing headaches. Use it religiously.
The Unpack Type: For When Your Function is a Dict Ambassador
You know **kwargs, right? It lets a function accept any number of keyword arguments. Typing it has been a journey. First it was **kwargs: Any, which is a free-for-all. Then we got TypedDict to define specific keyword sets, but how do you use one in a function signature? You don’t just slap it in.
This is where Unpack comes in. It allows you to use a TypedDict to type hint the expected keyword arguments in a function definition.
from typing import TypedDict, Unpack
class ConnectionParams(TypedDict):
host: str
port: int
timeout: float
ssl: bool
# This function expects all the keywords defined in ConnectionParams
def establish_connection(**kwargs: Unpack[ConnectionParams]) -> None:
print(f"Connecting to {kwargs['host']}:{kwargs['port']}")
# Valid calls:
establish_connection(host="example.com", port=443, timeout=30.0, ssl=True)
# Type checkers will correctly flag this as an error:
establish_connection(host="example.com") # Missing 'port', 'timeout', etc.
Why is it called Unpack? Because you’re conceptually unpacking the TypedDict into the keyword parameters. It’s the mirror of doing **some_dict in a function call. This is the only sane way to type complex **kwargs without losing your mind.
ParamSpec and Concatenate: Decorator Dark Arts
Brace yourself. This is the big one. You want to write a decorator that preserves the signature of the function it wraps. Before ParamSpec (PEP 612), this was basically black magic. Your decorator would always return Any or some mangled signature, destroying autocomplete and type safety.
ParamSpec (short for Parameter Specification) captures the signature of one function so you can use it to describe another. It’s like a TypeVar but for a function’s parameters and return type.
Let’s say you want a decorator that logs the arguments of any function it wraps.
from typing import TypeVar, ParamSpec, Callable, cast
import logging
P = ParamSpec("P") # Captures Parameters
R = TypeVar("R") # Captures Return type
def log_arguments(func: Callable[P, R]) -> Callable[P, R]:
"""A decorator that logs the function call before invoking it."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
arg_str = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
logging.info(f"Calling {func.__name__}({arg_str})")
return func(*args, **kwargs)
# We need a cast because the type system can't perfectly model the signature transformation
return cast(Callable[P, R], wrapper)
@log_arguments
def add_numbers(a: int, b: int) -> int:
return a + b
# The type of 'result' is still int, and the signature of add_numbers is preserved.
result = add_numbers(1, 2)
Now, what if your decorator adds an argument? This is where Concatenate comes in. It’s used with ParamSpec to say “the decorated function has the same signature as the original, but with this one extra parameter tacked on at the front.”
Imagine a decorator that injects a database connection as the first argument.
from typing import Concatenate, ParamSpec, TypeVar, Callable
P = ParamSpec("P")
R = TypeVar("R")
DBConnection = TypeVar("DBConnection")
def with_db_connection(func: Callable[Concatenate[DBConnection, P], R]) -> Callable[P, R]:
"""Decorator that provides a DB connection to the wrapped function."""
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
conn = get_database_connection() # Some function that returns a connection
return func(conn, *args, **kwargs) # Inject 'conn' as first arg
return wrapper
@with_db_connection
def get_user(conn: DBConnection, user_id: int) -> str:
# ... use conn to query the database ...
return "user_name"
# The decorated function NO longer takes a 'conn' argument.
# The decorator provides it. The signature is now (user_id: int) -> str.
name = get_user(123)
This is complex stuff. The key insight is that ParamSpec captures the relationship between parameters, allowing decorators to manipulate them in a type-safe way. You’ll know you need it when your linter starts screaming about your beautiful decorator. It’s a feature for library authors, not for everyday code, but when you need it, it’s a godsend.