skip to content

What does typing.TypedDict express that a plain dict[str, str] annotation cannot?

level: juniorimportance: must knowfreq 60%

answer

  1. A dict with known key names
  2. Per-key types, not one value type
  3. Enforced by the checker, not runtime
  4. It builds an ordinary dict
  5. Functional form for non-identifier keys

basics

~20 s

typing.TypedDict names each key of a dictionary and gives every key its own value type, so a type checker can flag a misspelled, missing or undeclared key. dict[str, str] only says string keys, string values.

solid answer

~40 s

A `TypedDict` describes a dictionary whose key names are known in advance. You declare it as a class body of `key: type` lines, and a type checker then enforces that set of keys and the type of each individual value: an undeclared key in a dict literal, a missing required key, or a wrong value type all become checker errors. `dict[str, str]` can only say that every key is a `str` and every value is a `str`, so `payload["prioroty"]` sails straight through. Two things matter at runtime: the class creates and returns an ordinary `dict`, and nothing is validated — the guarantees are entirely static. Use the functional form, `TypedDict("Row", {"ticket-id": int})`, when a key is not a valid identifier.

code

python · 11 lines
python
from typing import TypedDict, NotRequired

class Ticket(TypedDict):
    id: int
    title: str
    assignee: NotRequired[str]

t = Ticket(id=7, title="cold start times out")
print(type(t), t)
Row = TypedDict("Row", {"ticket-id": int, "from": str})
print(Row({"ticket-id": 7, "from": "queue"}))

go deeper

for a junior

Be ready to define it in one sentence and write the class-based form from memory: a class body of key-and-type lines, checked by a tool, producing an ordinary dict. Know that you subscript it with brackets, never with a dot.

for a middle

Explain the mechanics: what a checker actually flags, why the value it builds is a plain dict, and why isinstance against it raises TypeError. Know the functional form and the kind of key that forces you to use it.

for a senior

An interviewer expects you to place it correctly in a real system — inside a trusted boundary, after untrusted input has been parsed — and to say plainly that it buys zero runtime safety, so it never replaces validation at the edge.

for a principal

Own the question of when typed dictionaries are the right house style at all: they give the codebase shape checking with no conversion cost and no runtime object, at the price of structural rather than nominal identity, and no place to hang behaviour or invariants.

## The problem it solves A great deal of real Python moves dictionaries whose key names are fixed and known in advance: a decoded JSON payload, a config blob, a bundle of keyword arguments, a row handed to a library that wants a mapping. Annotating one of those as `dict[str, str]` says almost nothing useful. It commits every value to the same type — usually forcing `dict[str, Any]`, which turns checking off altogether — and it says nothing at all about *which* keys exist, so a typo in a key name is invisible until it raises `KeyError` in production. `typing.TypedDict` (PEP 589, added in Python 3.8) closes that gap. It describes a dictionary type by listing its keys and giving each key its own value type. ```python from typing import TypedDict class Ticket(TypedDict): id: int title: str priority: str ``` ## What a checker enforces Against that declaration, a static type checker will flag a dict literal that omits a required key, a literal that carries a key the class never declared, a value whose type does not match the declared one, and a read or write of a key that is not part of the shape. `ticket["prioroty"]` is an error at check time rather than a crash at run time, which is the entire point of the construct. Compatibility between `TypedDict` types is **structural**: a checker compares keys and value types, not class names. Two `TypedDict` classes that declare the same keys with the same types are mutually assignable, which is convenient for describing the same wire shape in two places and a limitation if you actually wanted two distinguishable types. ## What happens at runtime Nothing. Calling the class returns a plain `dict`: ```python t = Ticket(id=7, title="queue backlog", priority="high") type(t) # <class 'dict'> ``` The object has no special class, no `__slots__`, no attribute access — `t.id` raises `AttributeError`, you write `t["id"]`. Values are not coerced and not checked: `Ticket(id="T-42", priority=7)` builds happily, and so does a call that omits a required key. There is deliberately no runtime cost, and equally no runtime safety net. Because there is no class to test against, `isinstance(t, Ticket)` raises `TypeError: TypedDict does not support instance and class checks`. The introspection that *is* available comes from `typing`: `typing.is_typeddict(Ticket)` returns `True`, and `typing.get_type_hints(Ticket)` returns the declared key-to-type mapping. Those are the hooks a framework or a hand-rolled boundary validator uses. ## The functional form A class body cannot spell a key that is not a valid Python identifier, and wire formats are full of such keys. The functional form takes a dictionary of fields: ```python Row = TypedDict("Row", {"ticket-id": int, "from": str}) ``` An older keyword-argument form, `TypedDict("Row", id=int)`, was deprecated in 3.11 and **removed in Python 3.13** — it now raises `TypeError`. Passing no fields at all is deprecated too; pass `{}` for an empty shape. ## Nesting and inheritance Value types can themselves be `TypedDict` classes, which is how nested JSON is described, and a `TypedDict` may inherit from one or more other `TypedDict` classes to merge their keys. What it may not do is mix in an ordinary base class: `class Bad(Ticket, dict)` raises `TypeError: cannot inherit from both a TypedDict type and a non-TypedDict base class`. This is the usual first surprise for someone who expects `TypedDict` to behave like a normal class — it is a *type-level* description of a dictionary, not a class hierarchy. Nesting is worth a line of its own, because it is how most real payloads are described. A key's declared type can be another `TypedDict`, and checking then applies all the way down: ```python class Reporter(TypedDict): name: str email: str class Ticket(TypedDict): id: int reporter: Reporter # nested shape, checked to the leaf ``` Reading `ticket["reporter"]["emial"]` is an error at check time, two levels deep, which is exactly the class of typo that otherwise surfaces as a `KeyError` in production. ## Where it fits Reach for `TypedDict` when the data genuinely is and stays a dictionary and you want its shape checked for free. Since the checking is static, keep it on the trusted side of a boundary: decode and validate untrusted input first, then let the `TypedDict` document and check the shape everywhere inward. Later versions added per-key modifiers on top of the basic idea — `Required` and `NotRequired` in 3.11, `ReadOnly` in 3.13 — but the core contract is the one above: named keys, per-key types, checked by a tool, invisible at run time.

  • When would you reach for the functional TypedDict form instead of the class form?
    When a key is not a valid Python identifier or collides with a keyword — `TypedDict("Row", {"ticket-id": int, "from": str})`. A class body simply cannot spell those names. Note that the older keyword-argument form, `TypedDict("Row", id=int)`, was removed in Python 3.13, so the dictionary-of-fields form is the only functional form left.
  • Can a TypedDict inherit from an ordinary class such as dict?
    No. It may inherit from other TypedDict classes, which merges their keys, and it may be generic, but mixing in a non-TypedDict base raises `TypeError: cannot inherit from both a TypedDict type and a non-TypedDict base class`. TypedDict describes the shape of a dictionary at the type level; it is not a class you build a hierarchy from.
  • How would a library discover the declared shape of a TypedDict at runtime?
    `typing.is_typeddict(cls)` tells it that the object is a TypedDict class at all, and `typing.get_type_hints(cls)` returns the key-to-type mapping it declares. That is the whole runtime surface — enough for a framework to build a validator or a serializer, and nothing that happens automatically.

A TypedDict is a printed form with labelled fields, not a locked box: it tells everyone which fields the paper is supposed to have, but nothing stops someone handing you one with a field left blank.

saying these in an interview costs you the question

  • Thinks a TypedDict validates values at runtime
  • Says it creates a custom class instance, not a dict
  • Uses isinstance() against a TypedDict class
  • Believes dict[str, str] would catch a misspelled key
  • Expects attribute access such as ticket.id to work

context