While I tend to avoid *args
and **kwargs
in my function signatures, it’s not always
possible to do so without hurting API ergonomics. Especially when you need to write
functions that call other helper functions with the same signature.
Typing *args
and **kwargs
has always been a pain since you couldn’t annotate them
precisely before. For example, if all the positional and keyword arguments of a function had
the same type, you could do this:
def foo(*args: int, **kwargs: bool) -> None: ...
This implies that args
is a tuple where all the elements are integers, and kwargs
is a
dictionary where the keys are strings and the values are booleans.
On the flip side, you couldn’t annotate *args
and **kwargs
properly if the values of the
positional and keyword arguments had different types. In those cases, you’d have to fall
back to Any
, which defeats the purpose.
Consider this example:
def foo(*args: tuple[int, str], **kwargs: dict[str, bool | None]) -> None: ...
Here, the type checker sees each positional argument as a tuple of an integer and a string.
Plus, it considers each keyword argument as a dictionary where the keys are strings and the
values are either booleans or None
.
With the previous annotation, mypy
will reject this:
foo(*(1, "hello"), **{"key1": 1, "key2": False})
error: Argument 1 to "foo" has incompatible type "*tuple[int, str]";
expected "tuple[int, str]" [arg-type]
error: Argument 2 to "foo" has incompatible type "**dict[str, int]";
expected "dict[str, bool | None]" [arg-type]
Instead, it’ll accept the following:
foo((1, "hello"), kw1={"key1": 1, "key2": False})
You probably wanted to represent the former while the type checker wants the latter.
To annotate the second instance correctly, you’ll need to leverage bits of PEP-5891,
PEP-6462, PEP-6553, and PEP-6924. We’ll use Unpack
and TypedDict
from the
typing
module to achieve this. Here’s how:
from typing import TypedDict, Unpack # Python 3.12+
# from typing_extensions import TypedDict, Unpack # < Python 3.12
class Kw(TypedDict):
key1: int
key2: bool
def foo(*args: Unpack[tuple[int, str]], **kwargs: Unpack[Kw]) -> None: ...
args = (1, "hello")
kwargs: Kw = {"key1": 1, "key2": False}
foo(*args, **kwargs) # Ok
TypedDict
was introduced in Python 3.8 to allow you to annotate heterogeneous
dictionaries. If all the values of a dictionary have the same type, you can simply use
dict[str, T]
to annotate it. However, TypedDict
covers the case where all the keys of a
dictionary are strings but the type of the values varies.
The following example shows how you might annotate a heterogeneous dictionary:
from typing import TypedDict
class Movie(TypedDict):
name: str
year: int
movies: Movie = {"name": "Mad Max", "year": 2015}
Unpack
marks an object as having been unpacked.
Using TypedDict
with Unpack
allows us to communicate with the type checker so that each
positional and keyword argument isn’t mistakenly assumed as a tuple and a dictionary
respectively.
While the type checker is satisfied when you pass the *args
and **kwargs
as
foo(*args, **kwargs)
it’ll complain if you don’t pass all the keyword arguments:
foo(*args, key1=1) # error: Missing named argument "key2" for "foo"
To make all of the keywords optional, you could turn off the total
flag in the typed-dict
definition:
# ...
class Kw(TypedDict, total=False):
key1: int
key2: str
# ...
Or you could mark specific keywords as optional with typing.NotRequired
:
# ...
class Kw(TypedDict):
key1: int
key2: NotRequired[str]
# ...
This will let you pass an incomplete set of optional keyword arguments without the type checker yelling at you.
Fin!
Recent posts
- Injecting Pytest fixtures without cluttering test signatures
- Explicit method overriding with @typing.override
- Quicker startup with module-level __getattr__
- Docker mount revisited
- Topological sort
- Writing a circuit breaker in Go
- Discovering direnv
- Notes on building event-driven systems
- Bash namerefs for dynamic variable referencing
- Behind the blog