Python dictionaries are flexible, but that flexibility can hide mistakes. An API payload may require id, name, and active, while the code describes everything as dict[str, object]. With that generic type, a checker cannot know which keys exist, which keys are optional, or which value type belongs to each key. typing.TypedDict solves this by describing the expected structure of a dictionary without changing its runtime behavior.
This guide covers basic TypedDict declarations, required and optional keys, NotRequired and Required, inheritance, nested API payloads, discriminated variants, and the important boundary between static typing and real data validation.
What TypedDict represents
A TypedDict is a type declaration for dictionaries with known keys. At runtime, the value is still an ordinary dict.
from typing import TypedDict
class User(TypedDict):
id: int
name: str
active: bool
user: User = {
"id": 10,
"name": "Ana",
"active": True,
}Mypy or Pyright can report a missing key, a misspelled name, or an incompatible value.
wrong_user: User = {
"id": "10", # int expected
"name": "Ana",
"active": True,
}Python itself does not reject that dictionary during execution. The benefit appears when the project runs a static checker.
Why dict[str, object] is not enough
dict[str, object] only says that keys are strings and values may be arbitrary objects. It does not connect a particular key with a useful value type.
def display(user: dict[str, object]) -> str:
return user["name"].upper() # object does not guarantee upper()With TypedDict, the checker knows that user["name"] is a string.
def display(user: User) -> str:
return user["name"].upper()This precision builds on the concepts in the guide to Python type hints.
Optional keys with total=False
All keys are required by default. Use total=False when every declared key may be absent.
class UserUpdate(TypedDict, total=False):
name: str
active: bool
email: strThis shape is useful for PATCH operations where the caller sends only fields that should change.
def update_user(user_id: int, data: UserUpdate) -> None:
if "name" in data:
print(data["name"])An optional key is not the same as a nullable value. The key may be missing; when present, its value must still match the declared type.
NotRequired and Required
Use NotRequired when only selected fields may be absent. Use Required inside a total=False declaration when one key must remain present.
from typing import NotRequired, Required, TypedDict
class Profile(TypedDict):
id: int
name: str
nickname: NotRequired[str]
photo: NotRequired[str]
class PartialEvent(TypedDict, total=False):
kind: Required[str]
payload: object
source: strThese markers keep the contract readable without creating several nearly identical types.
Inheritance and schema composition
A TypedDict can inherit fields from another TypedDict.
class Entity(TypedDict):
id: int
class Product(Entity):
name: str
price: float
stock: intInheritance is useful for small shared groups, but complex schema hierarchies become difficult to evolve. Separate input, update, output, and persistence shapes when their requirements differ.
Functional syntax
The functional form is useful when keys are not valid Python identifiers.
Headers = TypedDict(
"Headers",
{
"content-type": str,
"x-request-id": str,
},
)For ordinary identifiers, class syntax is usually clearer and easier to document.
Modeling API responses
TypedDict works well for internal payloads or external responses after validation.
class Address(TypedDict):
city: str
state: str
postal_code: str
class ApiCustomer(TypedDict):
id: int
name: str
address: Address
tags: list[str]
def customer_city(customer: ApiCustomer) -> str:
return customer["address"]["city"]Do not annotate response.json() as a trusted TypedDict without checking the data. Network responses can omit keys, change formats, or contain unexpected values.
TypedDict does not validate at runtime
A TypedDict declaration does not convert values, reject extra keys, or produce validation errors. JSON, form data, queues, and database records still require runtime validation.
import json
text = '{"id": "ten", "name": 99, "active": true}'
data = json.loads(text)
# data exists even though it is incompatible with UserA safe boundary validates the object first and only then returns or narrows it to a precise type. Pydantic, dataclasses with explicit parsing, attrs, or custom validation may be appropriate.
Narrowing optional keys
When a field is NotRequired, check for membership before indexing it.
class Result(TypedDict):
value: int
warning: NotRequired[str]
def print_result(result: Result) -> None:
print(result["value"])
if "warning" in result:
print(result["warning"])get() is also available, but its result commonly includes None, which must be handled.
Structural compatibility and extra keys
TypedDict compatibility is structural. A richer dictionary type may be accepted where a smaller shape is expected, but requiredness, mutability, and value types impose important restrictions.
Avoid relying on obscure assignment rules. Keep consumer contracts focused and run the same checker in local development and CI.
TypedDict as input and output
class NewOrder(TypedDict):
customer_id: int
item_ids: list[int]
coupon: NotRequired[str]
class CreatedOrder(TypedDict):
id: int
status: str
total: float
def create_order(data: NewOrder) -> CreatedOrder:
return {
"id": 501,
"status": "created",
"total": 149.90,
}The signature documents the function boundary and improves autocomplete, reviews, and refactoring.
Discriminated variants with Literal
TypedDict combines especially well with Literal.
from typing import Literal
class Success(TypedDict):
kind: Literal["success"]
value: int
class Failure(TypedDict):
kind: Literal["failure"]
error: str
Response = Success | Failure
def process(response: Response) -> str:
if response["kind"] == "success":
return str(response["value"])
return response["error"]The discriminator lets the checker narrow the union to the correct shape.
Schema evolution
When an API evolves, adding optional fields is usually less disruptive than changing required keys. Removing a field or changing its value type should be versioned or migrated carefully. TypedDict helps static analysis reveal affected consumers.
Newer typing ecosystems also provide read-only field concepts. Verify support in the Python version and checker used by the project before adopting newer annotations in a public library.
Common mistakes
- Using TypedDict as JSON validation: it is static metadata only.
- Confusing missing with None: key optionality and nullable values are different.
- Using one schema for every operation: create, patch, and response payloads often need separate types.
- Casting unvalidated data: a cast silences the checker but does not repair values.
- Declaring every value as object: this loses most of the precision.
- Ignoring requiredness and mutability: structural compatibility still has rules.
TypedDict, dataclass, or Pydantic?
Choose TypedDict when the runtime value should remain a dictionary and the primary need is static checking. Choose dataclass when objects should expose attributes, methods, and explicit construction. Choose Pydantic when external data must be validated, converted, documented, or integrated with an API framework.
They can coexist. An input layer may validate with Pydantic and then pass lightweight TypedDict values to code that integrates with dictionary-based libraries.
Conclusion
typing.TypedDict turns informal dictionaries into checkable contracts. It describes required keys, optional keys, nested structures, and discriminated variants without changing the runtime object.
The official Python TypedDict documentation covers totality, inheritance, and requiredness markers. Use it for known dictionary shapes, run a static checker regularly, and keep runtime validation at every untrusted boundary.







