Python Type Hints - How to Use TypedDict

2021-05-10 A typographer’s clamp.

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 dicts, 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


📙👉Speed Up Your Django Tests👈📙


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: mypy, python