Back to Blog
Python

Python TypedDict: Typed Dictionaries in Practice

python typeddict: How to declare typed dictionaries with Python TypedDict, how type checkers enforce keys and values, and when to choose it over dataclass or NamedTuple.

TypedDictType HintsStatic TypingmypyPython
Illustration of a Python TypedDict representing a structured dictionary with typed keys and values, shown as a labeled document with type annotations.

When a function receives a dictionary whose values have different types, a static type checker can only describe it as dict[str, object]. Reading movie["year"] gives you object, so you need a cast or a runtime check before you can use it as an integer. python typeddict solves this by letting you declare the exact keys and value types of a dictionary, so the type checker verifies the structure at analysis time instead of leaving you to guess at runtime.

Declaring a TypedDict for Heterogeneous Dictionary Data

A TypedDict is declared with class syntax, where each annotation describes one key:

from typing import TypedDict class Movie(TypedDict): title: str year: int rating: float

The class body is executed at runtime only to collect annotations; it does not create a normal class. The type checker reads the annotations to build a schema for a dictionary. At runtime, Movie is a function-like object that accepts keyword arguments and returns a plain dictionary:

matrix = Movie(title="The Matrix", year=1999, rating=8.7) print(matrix) # {'title': 'The Matrix', 'year': 1999, 'rating': 8.7}

There is also a functional syntax for cases where the class form is inconvenient, such as when the key names come from a dynamic source:

Movie = TypedDict("Movie", {"title": str, "year": int, "rating": float})

Both forms produce the same type. The class form is generally preferred because it reads more naturally and supports inheritance and composition.

How Type Checkers Enforce TypedDict Keys and Values

Once a TypedDict is declared, type checkers such as mypy, pyright, and PyCharm's checker treat it as a precise dictionary type. Accessing a key returns the declared value type:

def describe(movie: Movie) -> str: return f"{movie['title']} ({movie['year']})"

Here movie["year"] is known to be int, so it can be passed directly to an f-string without a cast.

Assignment and construction are checked as well. The following is a type error because year is declared as int:

movie = Movie(title="The Matrix", year="1999") # type error: "year" expects int

Passing a plain dictionary where a TypedDict is expected also triggers a check. The type checker compares the dictionary's keys and value types against the schema, so a missing key or a mismatched value type is reported before the code runs.

Runtime Behavior: TypedDict Adds No Runtime Cost

The most important thing to understand about TypedDict is that it does nothing at runtime. The class body is not executed as a normal class body, no validation is performed, and no wrapper object is created. Instances are ordinary dictionaries:

>>> isinstance(Movie(title="The Matrix", year=1999, rating=8.7), dict) True

This has two practical consequences. First, there is no runtime overhead: a TypedDict is exactly as fast and memory-efficient as the equivalent plain dictionary. Second, there is no runtime protection. If a value with the wrong type is placed into the dictionary at runtime, nothing raises an error. TypedDict is a static analysis tool, not a validation library.

This is why TypedDict is a natural fit for data that crosses a boundary where static checking is not possible, such as a JSON response parsed from json.loads. The parser produces a plain dict, and you can annotate it as a TypedDict to give the type checker information about the expected structure.

Making Keys Optional With total=False

By default, every key in a TypedDict is required. The total=False flag makes every key optional:

class Movie(TypedDict, total=False): title: str year: int rating: float

With this declaration, a value may omit any of the three keys. The type checker allows Movie() and Movie(title="The Matrix"), but it also means that reading a key requires a check, because the key may be absent:

def year_or_unknown(movie: Movie) -> str: if "year" in movie: return str(movie["year"]) return "unknown"

The in check narrows the type so that movie["year"] is treated as int inside the branch.

Mixing Required and Optional Keys With Required and NotRequired

total=False applies to the whole class, which is too coarse when only some keys are optional. PEP 655 introduced Required and NotRequired to control each key individually. They are available from typing in Python 3.11 and from typing_extensions on earlier versions:

from typing import NotRequired, Required, TypedDict class Movie(TypedDict, total=False): title: Required[str] year: NotRequired[int] rating: NotRequired[float]

With total=False as the base, title is explicitly required while year and rating remain optional. The reverse combination also works: keep total=True and mark the optional keys with NotRequired.

Composing Nested TypedDicts

TypedDict values can themselves be TypedDicts, which is how you describe nested JSON structures:

class Director(TypedDict): name: str age: int class Movie(TypedDict): title: str director: Director

The type checker verifies the nested structure when the dictionary is constructed:

movie = Movie( title="The Matrix", director=Director(name="Lana Wachowski", age=57), )

Nested TypedDicts are checked recursively, so a missing key inside director is reported as an error at the construction site.

Choosing Between TypedDict, dataclass, and NamedTuple

TypedDict is not the only way to describe structured data. The right choice depends on how the data is used at runtime.

CriterionTypedDictdataclassNamedTuple
Runtime representationdictobjecttuple
Access styled["key"]d.keyd.key
Default valuesNoYesYes
MethodsNot intendedYesYes
Immutable by defaultNoNoYes
Best fitJSON-like dataDomain objects with behaviorFixed records with tuple semantics

Use a TypedDict when the data arrives as a dictionary, typically from JSON, and you want to keep it in that form for serialization or API compatibility. Use a dataclass when the data needs behavior, defaults, or attribute access and the extra conversion cost is acceptable. Use a NamedTuple when you need a lightweight, immutable record that behaves like a tuple.

Version Compatibility and typing_extensions

TypedDict was added to typing in Python 3.8. Required and NotRequired were added in Python 3.11. Code that must run on Python 3.8 through 3.10 can import all three from typing_extensions:

from typing_extensions import NotRequired, Required, TypedDict

The typing_extensions package is the standard backport for typing features, and type checkers treat its versions of these names identically to the standard-library versions. If you support a range of Python versions, importing from typing_extensions avoids version-conditional imports.

Common Mistakes and How to Avoid Them

The most common mistake is expecting runtime validation. A TypedDict does not check that a value is present or that a value has the right type when the program runs. If you need that, pair TypedDict with a validation library or use a dataclass with a custom __post_init__.

Another mistake is using isinstance to test for a TypedDict. Since instances are plain dictionaries, isinstance(value, Movie) is not a valid check; Movie is not a class in the runtime sense. Use isinstance(value, dict) and let the type checker handle the structural guarantee.

A third issue is assuming that extra keys are silently allowed. When you construct a TypedDict with a literal, type checkers flag keys that are not declared. If you intentionally allow arbitrary extra keys, you need a different type, such as dict[str, object] combined with a TypedDict for the known fields.

python typeddict: Practical Usage and Code Examples | RYUSLOG DEV