Skip to content
← All articles

Closed TypedDicts and typed extra items (PEP 728)

How to say this is the complete wire format, what extra_items buys beyond closed=True, and the payoff nobody expected: TypedDicts that are finally assignable to Mapping.

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=VT must keep the same VT. Narrowing it would be unsound, because a caller holding the parent type can write any VT into any extra key.
  • A child of a TypedDict with ReadOnly extra_items may narrow it, for exactly the covariance reason from the ReadOnly material: 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 to VT → the TypedDict is assignable to Mapping[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.