Python Type Hints - How to Use TypedDict

Take this function:
def get_sales_summary():
"""Return summary for yesterday’s sales."""
return {
"sales": 1_000,
"country": "UK",
"product_codes": ["SUYDT"],
}
What type hint should we add for its return value?
Maybe dict
?
We could use dict
:
def get_sales_summary() -> dict:
...
It’s true the function does return a dict
, but it’s an incomplete type. Using dict
without any parameters is equivalent to dict[Any, Any]
, and Any
disables type checking. This would mean our return dict
’s keys and values would not be type checked in callers.
For example, Mypy wouldn’t see any problem with this code:
sales_summary = get_sales_summary()
print("Sales:" + sales_summary["sales"])
But it crashes with a TypeError
:
$ python example.py
Traceback (most recent call last):
File "/.../example.py", line 11, in <module>
print("Sales:" + sales_summary["sales"])
TypeError: can only concatenate str (not "int") to str
Woops. Let’s get rid of that Any
Okay, what about dict[str, object]
?
We can improve on just dict
by parameterizing the types of its keys and values:
def get_sales_summary() -> dict[str, object]:
...
Now we’re accurately declaring the type of the keys as str
, which is great.
As the return values have mixed types, using object
is technically correct. It conveys “this could be anything” better than Any
. But it’s too restrictive, and the values’ types are now unknown at call sites.
For example, take this code:
sales_summary = get_sales_summary()
sales = sales_summary["sales"]
print("Sales per hour:", round(sales / 24, 2))
Mypy cannot tell that sales
is an int
, so it raises an error:
$ mypy example.py
example.py:12: error: Unsupported operand types for / ("object" and "int")
Found 1 error in 1 file (checked 1 source file)
To declare the correct type of the value, we need to use cast
, for example:
from typing import cast
sales_summary = get_sales_summary()
sales = cast(int, sales_summary["sales"])
print("Sales per hour:", round(sales / 24, 2))
This is not so useful as it places the burden of correct types away from the function into call sites. Also cast()
is not verified, so we could have type errors still.
Alright, dict[str, int | str | list[str]]
?
We can use the typing union operator, |
, to declare the possible types of values:
from __future__ import annotations
def get_sales_summary() -> dict[str, int | str | list[str]]:
...
This constrains our values to be int
or str
or list[str
]. The union type is more accurate than object
, which allowed literally infinite different types. But it does have a drawback - the type checker has has to assume each value could be any of the unioned types.
For example, we might try again to use "sales"
as an int
:
sales_summary = get_sales_summary()
sales = sales_summary["sales"]
print("Sales per hour:", round(sales / 24, 2))
But mypy tells us that the division operation won’t work in the cases that sales
might be a str
or list[str]
:
$ mypy example.py
example.py:17: error: Unsupported operand types for / ("str" and "int")
example.py:17: error: Unsupported operand types for / ("List[str]" and "int")
example.py:17: note: Left operand is of type "Union[int, str, List[str]]"
Found 2 errors in 1 file (checked 1 source file)
Again we’d need to use a nasty cast()
:
from typing import cast
sales_summary = get_sales_summary()
sales = cast(int, sales_summary["sales"])
print("Sales per hour:", round(sales / 24, 2))
This comes with the same problems as before.
Aha - a TypedDict
!
We now arrive at solution in the title. TypedDict
allows us to declare a structure for dict
s, mapping their keys (strings) to the types of their values.
TypedDict
was specified in PEP 589 and introduced in Python 3.8. On older versions of Python you can install it from typing-extensions.
We can use it like so:
from typing import TypedDict
class SalesSummary(TypedDict):
sales: int
country: str
product_codes: list[str]
def get_sales_summary() -> SalesSummary:
"""Return summary for yesterday’s sales."""
return {
"sales": 1_000,
"country": "UK",
"product_codes": ["SUYDT"],
}
Now we can access "sales"
without a cast()
:
sales_summary = get_sales_summary()
sales = sales_summary["sales"]
print("Sales per hour:", round(sales / 24, 2))
Mypy knows that sales
is an int
, so it allows the file to pass:
$ mypy v5.py
Success: no issues found in 1 source file
And we can run the code without error:
$ python v5.py
Sales per hour: 41.67
Great!
Fin
Thanks to David Foster for creating TypedDict
. If you want to try checking TypedDict
types at runtime, check out his trycast
project.
May your types be ever more accurate,
—Adam
Make your development more pleasant with Boost Your Django DX.
One summary email a week, no spam, I pinky promise.
Related posts: