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.