Using ParamSpec with Python Generics
I’ve been working on applying strict static typing to my Python package
class-resolver
and ran into an
interesting way of using
generics in
combination with parameter specification variables (i.e.,
ParamSpecs).
Normally, if you want to type annotate a function, you use the
Callable
,
which works like the following:
from collections.abc import Callable
#: the [int] represents a function that takes in a single integer,
#: and returns a single floating point number
IntToFloat = Callable[[int], float]
# this function fits the type annotation above, impl omitted
def square_root(x: int) -> float:
...
# simple example to show how to write functions that consume functions
def applies_int_to_float(func: Callable[[int], float], x: int) -> float:
return func(x)
>>> applies_int_to_float(square_root, 9)
3.0
However, if you want to get generic, you need to use a combination of ParamSpec for the input variable signature and TypeVar for the return value.
from collections.abc import Callable
from typing import TypeVar
X = TypeVar("X")
T = TypeVar("T")
# simple example to show how to write functions that consume functions
def applies_unary_function(func: Callable[[X], T], x: X) -> T:
return func(x)
>>> applies_unary_function(square_root, 9)
3.0
If you want to make the input fully generic, you can use ParamSpec
and
reference P.args
(or P.kwargs
, not shown here):
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
def applies_unary_function_generic(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return func(*args, **kwargs)
>>> applies_unary_function_generic(square_root, 9)
3.0
This gets even a bit more complicated if you want to type annotate a class that can take in a generic set of functions. Here’s an example on how this works:
from collections.abc import Callable
from typing import Generic, ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
class ListOfFunctions(Generic[P, T]):
def __init__(self, functions: list[Callable[P, T]]) -> None:
self.functions = functions
def identity(x: int) -> int:
return x
def plus_two(x: int) -> int:
return x + 2
my_list: ListOfFunctions[[int], int] = ListOfFunctions([identity, plus_two])
The most important part of this discovery for me was actually how to
type-annotate the resulting object, which magically is able to accept a list in
the place where the ParamSpec
should be.
Here’s the new way to write the same class using PEP-0695 type parameter syntax, introduced in Python 3.12:
from collections.abc import Callable
class ListOfFunctions[**P, T]:
def __init__(self, functions: list[Callable[P, T]]) -> None:
self.functions = functions
My two big wishes for typing in the future:
- make the builtin
any
be a valid substitution fortyping.Any
- make some cute syntax, so I don’t need to import
from collections.abc import Callable