Skip to content

Implement TypedDict extra_items= (PEP 728)#21712

Open
boogerlad wants to merge 11 commits into
python:masterfrom
boogerlad:pep728-extra-items
Open

Implement TypedDict extra_items= (PEP 728)#21712
boogerlad wants to merge 11 commits into
python:masterfrom
boogerlad:pep728-extra-items

Conversation

@boogerlad

Copy link
Copy Markdown

Fixes #18176
I'm going to be completely honest. This is 100% AI generated. I've read the contributing guidelines:

Pull requests from new contributors that are mostly generated by LLMs with little human input will be closed.

So of course, feel free to close it. However, at the very least the test cases are useful, and I know mypy will eventually have full PEP 728 support, so in the meantime, I made this to prototype the type system in a HTML-as-data library. It works well.

Feel free to cherry pick individual commits (57cab29 is independent and pre-existing issue on master), claim authorship, reuse the code in some way, or do nothing with it. Consider this as a donation in Fable xhigh tokens. I've attached the session transcripts for full transparency. It can even be resumed if desired. Summarized "thinking" traces are included.

Archive.zip
3f014f7a-b87f-4fdd-b1ae-2548b61bf640.jsonl is the implementation session.
262f1ec4-275b-466f-98df-6633f95c124e.jsonl is the review session.

Basically, the prompts are:

❯ gh issue view 18176
0ce09a1 Implements support for closed TypedDicts (PEP 728)
deeply analyze mypy and the spec link (download it) and determine the optimal method to add support for TypedDict extra_items=
❯ implement phase 1
❯ commit, and then work on phase 2
❯ were there any preexisting issues you discovered during implementation that should be fixed before moving onto phase 3?
❯ yes, create the cleanup commit + spec-conformance commit
❯ work on phase 3
❯ continue phase 3
❯ implement phase 4

and

❯ review the commits in pep728-extra-items. gh issue view 18176
0ce09a1 Implements support for closed TypedDicts (PEP 728) (already merged to master and released as part of mypy v2.2.0. This branch builds on that, but was not authored by this branch)
deeply analyze mypy and the spec link (download it) and determine if this branch adds support for TypedDict extra_items= optimally and correctly. Then, does that completely answer the question "is pep 728 fully supported?" with a "yes"? you may analyze and probe pyright here: /Users/boogerlad/mythos666/pyright to see how it implements pep 728
❯ Are any of the methods you used to verify correctness suitable to belong in mypy's test suite / this branch? Regarding "call-site **td unpacking into a callee...", is the pre-existing mypy leniency a desired behavior? or is it just because it was not possible before, and that it's actually desirable to do the follow-up? Were there any other pre-existing issues that you noticed that should be fixed? Plan what to do next
❯ do items 1-4

boogerlad added 10 commits July 9, 2026 18:45
Replace TypedDictType.is_closed with a first-class PEP 728 pseudo-item:
extra_items: Type | None plus extra_items_readonly: bool. None means
default-open; closed=True is stored as extra_items=UninhabitedType()
per the PEP's closed=True == extra_items=Never equivalence, and
is_closed remains available as a derived property so all existing
readers are unchanged.

This is a pure representation change with no user-visible behavior
difference: item() resolves missing keys through the pseudo-item,
every type visitor (translator, queries, traverser, expandtype, fixup,
indirection, deps, astmerge, astdiff snapshots) now visits extra_items,
constraint inference covers the pseudo-item, and both JSON and binary
cache formats serialize it (CACHE_VERSION bumped). join/meet keep their
boolean closed logic behind TODOs until non-Never extra_items becomes
constructible.

Groundwork for TypedDict extra_items= support (python#18176).
Accept the extra_items= class argument in both the class and functional
TypedDict syntaxes, with any ReadOnly[] qualifier unwrapped into
extra_items_readonly (Required[]/NotRequired[] are rejected, and
combining closed= with extra_items= is an error, per the PEP).

Inheritance treats the pseudo-item like a regular item: it is inherited
as-is, may only be redeclared over a read-only base pseudo-item (with a
narrower type), and new fields must be compatible with each base's
pseudo-item (non-required and equivalent for mutable extra_items,
assignable for read-only). Requiredness/closedness violations are
reported in semantic analysis; checks that need subtyping are deferred
to checker.check_typeddict_inheritance via TypedDictData, whose base
entries now carry the base pseudo-item mapped through type arguments.
Explicit closed=False errors if any base restricts extra items. The
extra_items keyword value is a type expression, so class keyword
analysis no longer rejects type variables in it.

Assignability, join, and meet now handle the pseudo-item through the
generalized item()/extra_item() machinery: read-only pseudo-items are
covariant, mutable ones require equivalence (subsuming "a closed type
must remain closed"), a join of incompatible pseudo-items widens to
read-only, and a meet of incompatible mutable pseudo-items is
uninhabited. Generic TypedDicts support extra_items referencing their
type variables in both syntaxes.

Use-site support (construction with extra keys, subscripting, plugin
methods, Unpack kwargs, narrowing) and documentation are the next
phase of python#18176.
Rename the extra_items_from field to merged_from: it holds the
TypedDicts merged in with ** in the experimental inline TypedDict
syntax and is unrelated to PEP 728 extra_items, which now lives on
the same class.

Also teach stubgen's functional-to-class TypedDict conversion about
the extra_items= argument; previously such definitions degraded to
"Incomplete" in generated stubs.
Per PEP 728's assignability rules, a TypedDict B is assignable to A
even when B lacks one of A's keys, provided A's item is read-only,
not required, and of top value type (ReadOnly[NotRequired[object]]):
that is exactly the implicit pseudo-item of a default-open TypedDict.

Previously this case was rejected unconditionally, which also made it
inconsistent with the equivalent extra_items=ReadOnly[object] spelling,
which is accepted through the pseudo-item comparison. Treat the missing
key in a default-open subtype as ReadOnly[NotRequired[object]] instead,
which only changes behavior when the supertype's item is exactly the
top type.
Implement the use-site semantics for the extra_items pseudo-item:

Construction: TypedDicts with a non-Never extra_items accept arbitrary
extra keys in dict literals and constructor calls, with values checked
against the extra_items type (including through the ReadOnly qualifier,
which does not restrict construction). The synthesized constructor
callable gains a **kwargs parameter of the extra_items type, so type
variables in extra_items are inferred from extra keyword arguments.
With --extra-checks, ** unpacking a TypedDict source is accepted when
the source pseudo-item is provably absorbed by the callee's (closed
sources always are; otherwise the source extra_items must be assignable
to the callee's).

Access and mutation: subscript reads of unknown keys have the
extra_items type; writes and TypedDict method calls treat extra keys as
non-required items, so get/pop/setdefault/del/update work on them, with
read-only extra_items rejecting all mutation.

Unpack: **kwargs: Unpack[TD] where TD has extra_items now accepts
arbitrary keyword arguments of that type, making it equivalent to the
corresponding explicit signature with typed **kwargs. ParamSpec
P.kwargs synthesis maps a callable's **kwargs type to extra_items
instead of dropping it, making the bound kwargs type more precise
(resolves an old TODO).

Also document closed= and extra_items= interactions in the TypedDict
docs. Completes the core of python#18176; Mapping/dict assignability and
values()/items() precision remain as follow-ups.
Implement the Mapping- and dict-interaction rules for TypedDicts with
closed=True or extra_items= set, whose complete value types are
statically known:

Assignability: such a TypedDict is assignable to Mapping[str, VT] when
all of its value types, including the extra_items pseudo-item, are
assignable to VT. It is additionally assignable to dict[str, VT] (and
MutableMapping[str, VT]) when every item is non-required, mutable, and
consistent with VT; dict[str, VT] remains not assignable to TypedDicts.
Overlap checks for Mapping types now consider the pseudo-item as well.

Precise views: values() and items() return views with the union of all
value types instead of object (via new default-plugin hooks), and
subscript reads accept arbitrary str keys, yielding that union.

dict-like TypedDicts: when every item is non-required, mutable, and
consistent with extra_items (tracked by the new
subtypes.typed_dict_dict_value_type helper), operations that are
normally unsafe become available with dict[str, VT] semantics:
clear(), popitem(), and __setitem__/__delitem__ with arbitrary str
keys.

The _TypedDict test fixture gains an items() method mirroring
typeshed's shape, and the dict fixture gains clear()/popitem().

This completes PEP 728 support (python#18176).
Two behaviors that already work but had no test pinning them:
extra_items may reference the TypedDict being defined (the pseudo-item
is checked recursively during construction), and a callable taking
**kwargs: Unpack[TD] with extra_items satisfies a Protocol declaring
the equivalent explicit signature, in both directions (PEP 728's
Unpack equivalence, at the protocol-member level).
…ll sites

Previously a **td actual mapped only its named items to formals, so the
extra keys a PEP 728 TypedDict may contain were silently dropped:
passing a TypedDict with extra_items=str to a function taking
**kwargs: int went unflagged. This leniency was load-bearing for
default-open TypedDicts, whose possible extras are untyped, but for a
TypedDict with extra_items= the extras type is declared and checking it
has no false positives (pyright already flags this).

Map the pseudo-item of a non-closed extra_items TypedDict to the
callee's **kwargs formal in map_actuals_to_formals, and have
ArgTypeExpander yield the extra_items type once the named items are
exhausted. When the callee has no **kwargs the extras stay ignored,
since they may all be absent (matching pyright). This also feeds
extra_items into generic **kwargs: T inference.
The redeclaration machinery resolved the requiredness of a read-only
field inherited from multiple sources with any(), silently promoting it
to required. This masked two invalid redeclarations and one base-class
conflict (typing conformance suite, typeddicts_readonly_inheritance
lines 94/106/132):

- redeclaring a mutable field as read-only (unsound: the subclass would
  no longer be assignable to the base, which permits mutation);
- redeclaring a ReadOnly[Required[...]] field as non-required;
- inheriting from two bases whose read-only fields disagree on
  requiredness, when the base declaring it non-required comes first in
  MRO (the reverse order is fine: a required redeclaration may
  strengthen a non-required read-only item).

Take requiredness from the primary source like the mutable path does,
so verify_requiredness_compatibility sees the mismatches, and add the
missing mutable-as-read-only check; the analogous rule already existed
for the extra_items pseudo-item. The 'not in base class' message
variant covers read-only fields, where 'can be deleted' would be wrong.

testTypedDictReadOnlyMixinTotality asserted the silent promotion for
C(B, A); both the conformance suite and pyright reject that order and
accept D(A, B), so the test now expects the error. This makes
typeddicts_readonly_inheritance.py fully conformant.
Extract TypedDictType.value_types_with_extra() to replace the three
copies of 'all value types plus the non-Never pseudo-item' in
subtypes.py, checkexpr.py, and plugins/default.py.

Also generalize TypedDict constraint inference from zip() to zipall(),
so a key named on only one side is matched against the other side's
extra_items pseudo-item. Previously such keys produced no constraints,
so e.g. passing a TypedDict with extra_items=int to a parameter of
TypedDict type {x: NotRequired[T]} left T unsolved (inferred Never with
a spurious arg-type error); it now infers T=int. Keys missing from a
default-open side still infer nothing.
@github-actions

This comment has been minimized.

@boogerlad

Copy link
Copy Markdown
Author

Hm, looking into the parallel tests with py314-ubuntu now

The native parser (forced on with --mypy-num-workers>0) reports a repeated
keyword argument as a blocking syntax error, which aborts semantic analysis
and suppresses the other errors in the file. Split the repeated-keyword case
out of testTypedDictExtraItemsInvalidArgument into a _no_parallel test,
mirroring testTypedDictWithDuplicateKeywordArguments_no_parallel.
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for TypedDict extra_items= and closed= (PEP 728)

1 participant