A TypedDict has always been open. class User(TypedDict): name: str
describes a dict that has at least a name — a value with extra keys still
satisfies it. That is the right default for reading someone else’s JSON, and
it is exactly wrong for describing a format you own.
Before PEP 728, “an unexpected key arrived from an external payload” was not expressible in the type system at all. You wrote a runtime validator and hoped nobody read the annotation as a promise.
PEP 728 lands in 3.15 and is available today via typing_extensions.
Two knobs
class Config(TypedDict, closed=True):
host: str
port: int
closed=True means no other keys are permitted. It is defined as sugar for
the more general form:
class Config(TypedDict, extra_items=Never):
host: str
port: int
extra_items=VT is the real primitive: keys beyond the declared ones are
permitted and have type VT. Extra items are always non-required —
there is no way to say “some unspecified key must be present”, which would
be meaningless anyway.
class Headers(TypedDict, extra_items=str):
content_type: str # declared, required
# any other key is allowed, and its value is a str
💡extra_items=Never and closed=True are the same thing. Why does the PEP bother with a keyword whose only job is to spell one value of another keyword?
click to reveal
Readability at the point of use, and it is not purely cosmetic.
closed=True is what you write on a wire-format definition, and it reads as
the sentence you meant: this is the complete set of fields. Someone reviewing
the diff does not have to reason about what Never means as a value type,
or notice that a value type of Never makes it impossible to ever
successfully assign an extra key.
There is also a compatibility argument. closed=True is the form most people
will write, so pinning it as an explicit spelling means checkers can produce
a targeted diagnostic — this key is not permitted because the TypedDict is
closed — rather than the generic message you would get from failing to
assign str to Never.
Inheritance: openness is inherited
This is where the design gets careful.
- A child of a closed TypedDict is closed.
-
A child of a TypedDict with mutable
extra_items=VTmust keep the sameVT. Narrowing it would be unsound, because a caller holding the parent type can write anyVTinto any extra key. -
A child of a TypedDict with
ReadOnlyextra_itemsmay narrow it, for exactly the covariance reason from theReadOnlymaterial: nobody can write through a read-only item, so a narrower value type is safe.
That last rule is what makes the feature composable rather than a
conversation-ender. A base type declaring extra_items: ReadOnly[object]
says “there may be more fields, I make no promise about them” and children
can sharpen it.
💡You are typing a webhook payload from a vendor. They document six fields and reserve the right to add more, and you only read three. Closed, open, or extra_items — and what goes wrong with each of the other two? click to reveal
Open — the default — is right, possibly with extra_items=ReadOnly[object]
if you want the intent visible.
Closed is a bug waiting to happen. You do not own the format. The vendor ships a seventh field on a Tuesday, and now every payload fails validation for a field you were never going to read. You have converted a non-event into an outage.
A concrete extra_items=str is a different bug. You would be asserting
that every field you have not declared is a string, which is a claim about
data you have explicitly said you do not know. The first nested object or
integer breaks it, and the failure lands in whatever code trusted the
annotation.
Now flip the scenario: it is your service’s config file, parsed at startup
and fully under your control. Now closed=True is right and valuable,
because a typo’d key in a config file should be a startup failure, not a
setting that silently does nothing. The rule is the same one as everywhere
else in this track: who owns the format?
The payoff: assignability to Mapping
Here is the part that is easy to miss and is arguably the whole point.
A plain TypedDict is not assignable to Mapping[str, VT] for any
useful VT. Because it is open, an unknown key of unknown type might be
present, so the checker cannot promise that every value is a VT. This is
why def log(fields: Mapping[str, str]) has never accepted your TypedDict
and you ended up with dict[str, Any] at the seam.
With extra_items the promise becomes checkable:
-
extra_items=VT, and every declared item’s type assignable toVT→ the TypedDict is assignable toMapping[str, VT] -
the same, with everything mutable and required → assignable to
dict[str, VT]
So a TypedDict can finally flow into the ordinary mapping-shaped APIs it always structurally resembled.
class Tags(TypedDict, extra_items=str):
env: str
service: str
def emit(fields: Mapping[str, str]) -> None: ...
tags: Tags = {"env": "prod", "service": "api", "region": "eu-west-1"}
emit(tags) # now type-checks
💡Why does the dict[str, VT] case additionally require every declared item to be mutable and required, when Mapping[str, VT] does not?
click to reveal
Because dict supports __delitem__ and __setitem__, and Mapping does
not.
If a declared item were ReadOnly, handing the value out as a
dict[str, VT] would let the receiver assign to a key the type said was
immutable. If a declared item were NotRequired, the receiver could
del d["env"] — legal on a dict[str, str] — and produce a value that no
longer satisfies Tags, with no error anywhere.
Mapping has neither operation, so neither problem arises, and the weaker
precondition suffices. It is the same reasoning that makes read-only protocol
members covariant and mutable ones invariant: the set of operations the
destination type permits determines how much the source is allowed to
differ. Once you have that sentence, most of the surprising rules in this
track stop being surprising.
Today
On 3.12–3.14, import from typing_extensions:
from typing_extensions import TypedDict # note: not typing's
The typing_extensions re-export is required because the class keyword
arguments are new; typing.TypedDict on an older runtime will not accept
closed= or extra_items=. Checker support arrived before runtime support,
which is the usual order for typing features and the reason
typing_extensions exists at all.