65.7 TypedDict: Typed Dictionaries
Right, so you’ve got a dictionary. You know what’s in it. You’ve given the keys nice, meaningful names like user_id and email_address. But when you pass this dict to a function, all type checkers see is dict[str, object]. They have no idea if user_id is a string, an integer, or a particularly stubborn boolean. This is where TypedDict rides in on a white horse. It lets you declare the expected types for specific keys in a dictionary, finally giving structure to one of Python’s most useful but anarchic data structures.
Think of it as a formal contract for your dict’s shape. You’re telling the type checker, “Hey, when I say ‘config’, I mean a dictionary that must have these keys and their values must be these types.” It’s a fantastic bridge between the untyped wild west of JSON APIs or configuration files and the type-safe code you’re trying to write.
The Two Flavors of TypedDict Definition
You can define a TypedDict using two syntaxes, and the choice is mostly about style and what version of Python you’re supporting. The first is the class-based syntax, which feels familiar if you’ve ever written a dataclass.
from typing import TypedDict
class UserProfile(TypedDict):
user_id: int
name: str
email: str | None # Because sometimes people don't want to share it
is_active: bool
# Now you can use it
profile: UserProfile = {
'user_id': 42,
'name': 'Arthur Dent',
'email': 'arthur@earth.com',
'is_active': True
}
The second, introduced in Python 3.6, uses a more literal syntax. This is handy for inline definitions or if you just prefer the look.
from typing import TypedDict
UserProfile = TypedDict('UserProfile', {
'user_id': int,
'name': str,
'email': str | None,
'is_active': bool
})
Both are perfectly valid. The class syntax is generally cleaner, especially as your TypedDict grows and you need to add methods (more on that later).
Total and Non-Total: The Required vs. Optional Key Distinction
Here’s the first “gotcha,” and it’s a big one. By default, every key you define in a TypedDict is required. If you create a UserProfile, you must provide a value for every single key, including email: str | None. This is called a “total” TypedDict.
But the real world is messy. Maybe you have a dict where half the keys are optional. For this, you set total=False when defining your class. This flips the default: now every key is considered optional unless you explicitly mark it with Required.
from typing import TypedDict, Required
class Config(TypedDict, total=False):
# These are all optional
timeout: float
retries: int
# But this one is mandatory
url: Required[str]
# This is valid
config_a: Config = {'url': 'http://example.com'}
# So is this
config_b: Config = {'url': 'http://example.com', 'timeout': 5.0}
# This would make a type checker throw a fit: missing required key 'url'
config_invalid: Config = {'timeout': 5.0}
Mixing this up is a classic pitfall. You’ll define a TypedDict for some API response, forget to set total=False for the optional fields, and then your type checker will scream bloody murder every time you get a response missing a few keys. Always ask yourself: “Is this key always present, or just sometimes?”
Inheritance and The Method No One Tells You About
You can inherit from other TypedDict classes to extend them, which is incredibly useful for building up complex structures from simple ones.
class BaseUser(TypedDict):
id: int
login: str
class AdminUser(BaseUser):
permissions: list[str]
is_superuser: bool
Now, an AdminUser is required to have all the keys of BaseUser plus its own. Neat.
Here’s the insider tip: you can also define methods on a class-based TypedDict. No, really. They can’t touch self (because there is no self—it’s still just a dict), but they are fantastic for defining alternate constructors.
class Coordinates(TypedDict):
x: float
y: float
@classmethod
def from_tuple(cls, pair: tuple[float, float]) -> 'Coordinates':
return cls(x=pair[0], y=pair[1])
# Usage
point = Coordinates.from_tuple((1.5, 4.2))
This is a killer feature for keeping related functionality neatly bundled together without breaking the dict’s simplicity.
The Harsh Reality: Runtime is a Lie
This is the most important thing to remember: TypedDict is a paper tiger at runtime. It’s all a lie for the benefit of your type checker. The actual validation doesn’t happen. If you do this:
fake_profile: UserProfile = {
'user_id': 'not_a_number', # This is a string, not an int!
'name': 'Arthur Dent',
'email': None,
'is_active': True
}
Python will run it without a single complaint. You’ll get a dict with a string value for 'user_id', and the error will only surface later when your code tries to use it as an integer. TypedDict doesn’t validate data; it only validates type annotations. For actual data validation, you need a real library like pydantic. TypedDict is the blueprint, not the building inspector.
So use it to make your intentions crystal clear to both your type checker and your future self. It turns a common source of runtime errors into a neatly caught set of static type errors, and that’s a win, even if it feels a bit like magic. Just remember, the magic only works before you hit the “run” button.