You have a dict that represents a structured record — {"name": ..., "age": ..., "tags": [...]}. A type hint of dict[str, Any] tells you nothing useful. TypedDict lets you describe the keys and their types so the checker can verify usage.
Two declaration styles
Class-style: class User(TypedDict): name: str; age: int. Or functional: User = TypedDict("User", {"name": str, "age": int}). The class style reads better; the functional style is needed when keys aren't valid Python identifiers (like keys with hyphens). Both produce the same type at runtime.
NotRequired and total=False
By default, every field in a TypedDict is required. total=False in the class declaration makes them all optional. NotRequired[X] (3.11+) marks specific fields optional while keeping the rest required. Required[X] does the inverse — required field in an otherwise total=False dict.
TypedDict at runtime — still a dict
A TypedDict is a dict at runtime. isinstance(user, dict) is True. The type information is purely for static checking. Don't try to call User(name=..., age=...) as a constructor that validates — it just makes a dict.
When to choose TypedDict vs dataclass vs Pydantic
TypedDict when you have JSON-shaped data and want types. Dataclass when you want a Python class with attributes (obj.name not obj["name"]). Pydantic when you also need runtime validation and rich field semantics. Pippa uses Pydantic heavily for FastAPI request/response models.
Code
TypedDict — class style·python
from typing import TypedDict
class User(TypedDict):
name: str
age: int
email: str
def greet(u: User) -> str:
return f"hi {u['name']} ({u['age']})"
u: User = {"name": "alice", "age": 30, "email": "a@x.com"}
print(greet(u))
# At runtime, u is a regular dict
print(type(u)) # <class 'dict'>
print(isinstance(u, dict)) # True
Optional fields — NotRequired (3.11+)·python
from typing import TypedDict, NotRequired
class User(TypedDict):
name: str
age: int
nickname: NotRequired[str] # optional
bio: NotRequired[str] # optional
minimal: User = {"name": "alice", "age": 30} # OK
full: User = {"name": "bob", "age": 25, "nickname": "b", "bio": "hi"}
# Pre-3.11 — total=False makes everything optional
class Settings(TypedDict, total=False):
theme: str
font: str
debug: bool
s: Settings = {} # OK — all fields optional
s2: Settings = {"theme": "dark"} # OK
Functional style — for non-identifier keys·python
from typing import TypedDict
# Sometimes API responses have keys that aren't valid Python names
# Functional style handles this
GoogleApiResponse = TypedDict("GoogleApiResponse", {
"status": str,
"error-code": int, # hyphen in key — invalid identifier
"User-Agent": str, # capital and hyphen
})
resp: GoogleApiResponse = {
"status": "ok",
"error-code": 0,
"User-Agent": "...",
}
TypedDict vs dataclass — pick the shape·python
from typing import TypedDict
from dataclasses import dataclass
# TypedDict — when you want a dict with checked structure
class UserDict(TypedDict):
name: str
age: int
def use_dict(u: UserDict):
print(u["name"]) # bracket access
# Dataclass — when you want a class with attributes
@dataclass
class UserClass:
name: str
age: int
def use_class(u: UserClass):
print(u.name) # attribute access
# JSON in/out — TypedDict is closer to the wire format
# Domain logic — dataclass reads better
Define a TypedDictStock with required fields ticker: str, price: float, shares: int, and NotRequired fields note: str and tags: list[str]. Write a function total_value(s: Stock) -> float that returns price * shares. Build several Stock dicts (some with optional fields, some without) and call the function. Verify mypy is happy by mentally running the type checker through it.
Progress
Progress is local-only — sign in to sync across devices.