Annotating args and kwargs in Python
Redowan Delowar
January 8, 2024
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:
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:
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:
Instead, it'll accept the following:
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-589],
[PEP-646], [PEP-655], and [PEP-692]. We'll use Unpack and TypedDict from the typing
module to achieve this. Here's how:
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:
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
it'll complain if you don't pass all the keyword arguments:
To make all of the keywords optional, you could turn off the total flag in the typed-dict
definition:
Or you could mark specific keywords as optional with typing.NotRequired:
This will let you pass an incomplete set of optional keyword arguments without the type
checker yelling at you.
Fin!
[pep-589]:
https://peps.python.org/pep-0589/
[pep-646]:
https://peps.python.org/pep-0646/
[pep-655]:
https://peps.python.org/pep-0655/
[pep-692]:
https://peps.python.org/pep-0692/
Discussion in the ATmosphere