PEP: 728
Title: TypedDict with Typed Extra Items
Author: Zixuan James Li <p359101898@gmail.com>
Sponsor: Jelle Zijlstra <jelle.zijlstra@gmail.com>
Discussions-To: https://discuss.python.org/t/pep-728-typeddict-with-typed-extra-items/45443
Status: Final
Type: Standards Track
Topic: Typing
Created: 12-Sep-2023
Python-Version: 3.15
Post-History: `09-Feb-2024 <https://discuss.python.org/t/pep-728-typeddict-with-typed-extra-items/45443>`__,
Resolution: `15-Aug-2025 <https://discuss.python.org/t/pep-728-typeddict-with-typed-extra-items/45443/159>`__

.. canonical-doc:: :class:`~typing.TypedDict`


Abstract
========

This PEP adds two class parameters, ``closed`` and ``extra_items``
to type the extra items on a :class:`~typing.TypedDict`. This addresses the
need to define closed TypedDict types or to type a subset of keys that might
appear in a ``dict`` while permitting additional items of a specified type.

Motivation
==========

A :py:class:`typing.TypedDict` type can annotate the value type of each known
item in a dictionary. However, due to :term:`typing:structural`
:term:`assignability <typing:assignable>`, a TypedDict can have extra items
that are not visible through its type. There is currently no way to restrict
the types of items that might be present in the TypedDict type's
:term:`consistent subtypes <typing:consistent subtype>`.

Disallowing Extra Items Explicitly
----------------------------------

The current behavior of TypedDict prevents users from defining a
TypedDict type when it is expected that the type contains no extra items.

Due to the possible presence of extra items, type checkers cannot infer more
precise return types for ``.items()`` and ``.values()`` on a TypedDict.
This can be resolved by
`defining a closed TypedDict type <https://github.com/python/mypy/issues/7981>`__.

Another possible use case for this is a sound way to
`enable type narrowing <https://github.com/python/mypy/issues/9953>`__ with the
``in`` check::

    class Movie(TypedDict):
        name: str
        director: str

    class Book(TypedDict):
        name: str
        author: str

    def fun(entry: Movie | Book) -> None:
        if "author" in entry:
            reveal_type(entry)  # Revealed type is still 'Movie | Book'

Nothing prevents a ``dict`` that is assignable with ``Movie`` to have the
``author`` key, and under the current specification it would be incorrect for
the type checker to :term:`typing:narrow` its type.

Allowing Extra Items of a Certain Type
--------------------------------------

For supporting API interfaces or legacy codebase where only a subset of possible
keys are known, it would be useful to explicitly specify extra items of certain
value types.

However, the typing spec is more restrictive when checking the construction of a
TypedDict, `preventing users <https://github.com/python/mypy/issues/4617>`__
from doing this::

    class MovieBase(TypedDict):
        name: str

    def foo(movie: MovieBase) -> None:
        # movie can have extra items that are not visible through MovieBase
        ...

    movie: MovieBase = {"name": "Blade Runner", "year": 1982}  # Not OK
    foo({"name": "Blade Runner", "year": 1982})  # Not OK

While the restriction is enforced when constructing a TypedDict, due to
:term:`typing:structural` :term:`assignability <typing:assignable>`, the
TypedDict may have extra items that are not visible through its type.
For example::

    class Movie(MovieBase):
        year: int

    movie: Movie = {"name": "Blade Runner", "year": 1982}
    foo(movie)  # OK

It is not possible to acknowledge the existence of the extra items through
``in`` checks and access them without breaking type safety, even though they
might exist from some :term:`consistent subtypes <typing:consistent subtype>` of
``MovieBase``::

    def bar(movie: MovieBase) -> None:
        if "year" in movie:
            reveal_type(movie["year"])  # Error: TypedDict 'MovieBase' has no key 'year'

Some workarounds have already been implemented to allow
extra items, but none of them is ideal. For mypy,
``--disable-error-code=typeddict-unknown-key``
`suppresses type checking error <https://github.com/python/mypy/pull/14225>`__
specifically for unknown keys on TypedDict. This sacrifices type safety over
flexibility, and it does not offer a way to specify that the TypedDict type
expects additional keys whose value types are assignable with a certain type.

Support Additional Keys for ``Unpack``
--------------------------------------

:pep:`692` adds a way to precisely annotate the types of individual keyword
arguments represented by ``**kwargs`` using TypedDict with ``Unpack``. However,
because TypedDict cannot be defined to accept arbitrary extra items, it is not
possible to `allow additional keyword arguments
<https://discuss.python.org/t/pep-692-using-typeddict-for-more-precise-kwargs-typing/17314/87>`__
that are not known at the time the TypedDict is defined.

Given the usage of pre-:pep:`692` type annotation for ``**kwargs`` in existing
codebases, it will be valuable to accept and type extra items on TypedDict so
that the old typing behavior can be supported in combination with ``Unpack``.

Previous Discussions
--------------------

The new features introduced in this PEP would address several long-standing feature
requests in the type system. Previous discussions include:

- `Mypy issue <https://github.com/python/mypy/issues/7981>`__ asking for a "final TypedDict"
  (2019). While the discussion focuses on the ``@final`` decorator, the underlying feature request
  would be addressed by this PEP.
- `Mailing list thread <https://mail.python.org/archives/list/typing-sig@python.org/thread/66RITIHDQHVTUMJHH2ORSNWZ6DOPM367/>`__
  asking for a way to say that a ``TypedDict`` can contain arbitrary extra keys (2020).
- `Discussion <https://discuss.python.org/t/pep-692-using-typeddict-for-more-precise-kwargs-typing/17314/87>`__
  about an extension of the ``Unpack`` mechanism introduced by :pep:`692` (2023).
- `PEP 705 <https://discuss.python.org/t/pep-705-typeddict-read-only-and-other-keys/36457>`__ in an earlier
  draft proposed a similar feature (2023); it was removed to keep that PEP simpler.
- `Discussion <https://discuss.python.org/t/do-we-want-an-exact-typeddict-if-so-how-final-extras-never/44418>`__
  about an "exact" ``TypedDict`` (2024).

Rationale
=========

Suppose we want a type that allows extra items of type ``str`` on a TypedDict.

`Index Signatures
<https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures>`__
in TypeScript allow this:

.. code-block:: typescript

    type Foo = {
        a: string
        [key: string]: string
    }

This proposal aims to support a similar feature without syntax changes,
offering a natural extension to the existing assignability rules.

We propose to add a class parameter ``extra_items`` to TypedDict.
It accepts a :term:`typing:type expression` as the argument; when it is present,
extra items are allowed, and their value types must be assignable to the
type expression value.

An application of this is to disallow extra items. We propose to add a
``closed`` class parameter, which only accepts a literal ``True`` or ``False``
as the argument. It should be a runtime error when ``closed`` and
``extra_items`` are used at the same time.

Different from index signatures, the types of the known items do not need to be
assignable to the ``extra_items`` argument.

There are some advantages to this approach:

- We can build on top of the `assignability rules defined in the typing spec
  <https://typing.python.org/en/latest/spec/typeddict.html#assignability>`__,
  where ``extra_items`` can be treated as a pseudo-item.

- There is no need to introduce a grammar change to specify the type of the
  extra items.

- We can precisely type the extra items without requiring the value types of the
  known items to be :term:`typing:assignable` to ``extra_items``.

- We do not lose backwards compatibility as both ``extra_items`` and ``closed``
  are opt-in only features.

Specification
=============

This specification is structured to parallel :pep:`589` to highlight changes to
the original TypedDict specification.

If ``extra_items`` is specified, extra items are treated as :ref:`non-required
<typing:required-notrequired>`
items matching the ``extra_items`` argument, whose keys are allowed when
determining `supported and unsupported operations
<https://typing.python.org/en/latest/spec/typeddict.html#supported-and-unsupported-operations>`__.

The ``extra_items`` Class Parameter
-----------------------------------

By default ``extra_items`` is unset.  For a TypedDict type that specifies
``extra_items``, during construction, the value type of each unknown item
is expected to be non-required and assignable to the ``extra_items`` argument.
For example::

    class Movie(TypedDict, extra_items=bool):
        name: str

    a: Movie = {"name": "Blade Runner", "novel_adaptation": True}  # OK
    b: Movie = {
        "name": "Blade Runner",
        "year": 1982,  # Not OK. 'int' is not assignable to 'bool'
    }

Here, ``extra_items=bool`` specifies that items other than ``'name'``
have a value type of ``bool`` and are non-required.

The alternative inline syntax is also supported::

    Movie = TypedDict("Movie", {"name": str}, extra_items=bool)

Accessing extra items is allowed. Type checkers must infer their value type from
the ``extra_items`` argument::

    def f(movie: Movie) -> None:
        reveal_type(movie["name"])              # Revealed type is 'str'
        reveal_type(movie["novel_adaptation"])  # Revealed type is 'bool'

``extra_items`` is inherited through subclassing::

    class MovieBase(TypedDict, extra_items=ReadOnly[int | None]):
        name: str

    class Movie(MovieBase):
        year: int

    a: Movie = {"name": "Blade Runner", "year": None}  # Not OK. 'None' is incompatible with 'int'
    b: Movie = {
        "name": "Blade Runner",
        "year": 1982,
        "other_extra_key": None,
    }  # OK

Here, ``'year'`` in ``a`` is an extra key defined on ``Movie`` whose value type
is ``int``. ``'other_extra_key'`` in ``b`` is another extra key whose value type
must be assignable to the value of ``extra_items`` defined on ``MovieBase``.

.. _typed-dict-closed:

The ``closed`` Class Parameter
------------------------------

When neither ``extra_items`` nor ``closed=True`` is specified, ``closed=False``
is assumed. The TypedDict should allow non-required extra items of value type
``ReadOnly[object]`` during inheritance or assignability checks, to
preserve the default TypedDict behavior. Extra keys included in TypedDict
object construction should still be caught, as mentioned in TypedDict's
`typing spec
<https://typing.python.org/en/latest/spec/typeddict.html#supported-and-unsupported-operations.>`__.

When ``closed=True`` is set, no extra items are allowed. This is equivalent to
``extra_items=Never``, because there can't be a value type that is assignable to
:class:`~typing.Never`. It is a runtime error to use the ``closed`` and
``extra_items`` parameters in the same TypedDict definition.

Similar to ``total``, only a literal ``True`` or ``False`` is supported as the
value of the ``closed`` argument. Type checkers should reject any non-literal value.

Passing ``closed=False`` explicitly requests the default TypedDict behavior,
where arbitrary other keys may be present and subclasses may add arbitrary items.
It is a type checker error to pass ``closed=False`` if a superclass has
``closed=True`` or sets ``extra_items``.

If ``closed`` is not provided, the behavior is inherited from the superclass.
If the superclass is TypedDict itself or the superclass does not have ``closed=True``
or the ``extra_items`` parameter, the previous TypedDict behavior is preserved:
arbitrary extra items are allowed. If the superclass has ``closed=True``, the
child class is also closed::

    class BaseMovie(TypedDict, closed=True):
        name: str

    class MovieA(BaseMovie):  # OK, still closed
        pass

    class MovieB(BaseMovie, closed=True):  # OK, but redundant
        pass

    class MovieC(BaseMovie, closed=False):  # Type checker error
        pass

As a consequence of ``closed=True`` being equivalent to ``extra_items=Never``,
the same rules that apply to ``extra_items=Never`` also apply to
``closed=True``. While they both have the same effect, ``closed=True`` is
preferred over ``extra_items=Never``.

It is possible to use ``closed=True`` when subclassing if the ``extra_items``
argument is a read-only type::

    class Movie(TypedDict, extra_items=ReadOnly[str]):
        pass

    class MovieClosed(Movie, closed=True):  # OK
        pass

    class MovieNever(Movie, extra_items=Never):  # OK, but 'closed=True' is preferred
        pass

This will be further discussed in
:ref:`a later section <pep728-inheritance-read-only>`.

``closed`` is also supported with the functional syntax::

    Movie = TypedDict("Movie", {"name": str}, closed=True)

Interaction with Totality
-------------------------

It is an error to use ``Required[]`` or ``NotRequired[]`` with ``extra_items``.
``total=False`` and ``total=True`` have no effect on ``extra_items`` itself.

The extra items are non-required, regardless of the `totality
<https://typing.python.org/en/latest/spec/typeddict.html#totality>`__ of the
TypedDict. `Operations
<https://typing.python.org/en/latest/spec/typeddict.html#supported-and-unsupported-operations>`__
that are available to ``NotRequired`` items should also be available to the
extra items::

    class Movie(TypedDict, extra_items=int):
        name: str

    def f(movie: Movie) -> None:
        del movie["name"]  # Not OK. The value type of 'name' is 'Required[str]'
        del movie["year"]  # OK. The value type of 'year' is 'NotRequired[int]'

Interaction with ``Unpack``
---------------------------

For type checking purposes, ``Unpack[SomeTypedDict]`` with extra items should be
treated as its equivalent in regular parameters, and the existing rules for
function parameters still apply::

    class MovieNoExtra(TypedDict):
        name: str

    class MovieExtra(TypedDict, extra_items=int):
        name: str

    def f(**kwargs: Unpack[MovieNoExtra]) -> None: ...
    def g(**kwargs: Unpack[MovieExtra]) -> None: ...

    # Should be equivalent to:
    def f(*, name: str) -> None: ...
    def g(*, name: str, **kwargs: int) -> None: ...

    f(name="No Country for Old Men", year=2007) # Not OK. Unrecognized item
    g(name="No Country for Old Men", year=2007) # OK

Interaction with Read-only Items
--------------------------------

When the ``extra_items`` argument is annotated with the ``ReadOnly[]``
:term:`typing:type qualifier`, the extra items on the TypedDict have the
properties of read-only items. This interacts with inheritance rules specified
in :ref:`Read-only Items <typing:readonly>`.

Notably, if the TypedDict type specifies ``extra_items`` to be read-only,
subclasses of the TypedDict type may redeclare ``extra_items``.

Because a non-closed TypedDict type implicitly allows non-required extra items
of value type ``ReadOnly[object]``, its subclass can override the
``extra_items`` argument with more specific types.

More details are discussed in the later sections.

Inheritance
-----------

``extra_items`` is inherited in a similar way as a regular ``key: value_type``
item. As with the other keys, the `inheritance rules
<https://typing.python.org/en/latest/spec/typeddict.html#inheritance>`__
and :ref:`Read-only Items <typing:readonly>` inheritance rules apply.

We need to reinterpret these rules to define how ``extra_items`` interacts with
them.

    * Changing a field type of a parent TypedDict class in a subclass is not allowed.

First, it is not allowed to change the value of ``extra_items`` in a subclass
unless it is declared to be ``ReadOnly`` in the superclass::

    class Parent(TypedDict, extra_items=int | None):
        pass

    class Child(Parent, extra_items=int): # Not OK. Like any other TypedDict item, extra_items's type cannot be changed
        pass

Second, ``extra_items=T`` effectively defines the value type of any unnamed
items accepted to the TypedDict and marks them as non-required. Thus, the above
restriction applies to any additional items defined in a subclass. For each item
added in a subclass, all of the following conditions should apply:

.. _pep728-inheritance-read-only:

- If ``extra_items`` is read-only

  - The item can be either required or non-required

  - The item's value type is :term:`typing:assignable` to ``T``

- If ``extra_items`` is not read-only

  - The item is non-required

  - The item's value type is :term:`typing:consistent` with ``T``

- If ``extra_items`` is not overridden, the subclass inherits it as-is.

For example::

    class MovieBase(TypedDict, extra_items=int | None):
        name: str

    class MovieRequiredYear(MovieBase):  # Not OK. Required key 'year' is not known to 'MovieBase'
        year: int | None

    class MovieNotRequiredYear(MovieBase):  # Not OK. 'int | None' is not consistent with 'int'
        year: NotRequired[int]

    class MovieWithYear(MovieBase):  # OK
        year: NotRequired[int | None]

    class BookBase(TypedDict, extra_items=ReadOnly[int | str]):
        title: str

    class Book(BookBase, extra_items=str):  # OK
        year: int  # OK

An important side effect of the inheritance rules is that we can define a
TypedDict type that disallows additional items::

    class MovieClosed(TypedDict, extra_items=Never):
        name: str

Here, passing the value :class:`~typing.Never` to ``extra_items`` specifies that
there can be no other keys in ``MovieFinal`` other than the known ones.
Because of its potential common use, there is a preferred alternative::

    class MovieClosed(TypedDict, closed=True):
        name: str

where we implicitly assume that ``extra_items=Never``.

Assignability
-------------

Let ``S`` be the set of keys of the explicitly defined items on a TypedDict
type. If it specifies ``extra_items=T``, the TypedDict type is considered to
have an infinite set of items that all satisfy the following conditions.

- If ``extra_items`` is read-only:

  - The key's value type is :term:`typing:assignable` to ``T``.

  - The key is not in ``S``.

- If ``extra_items`` is not read-only:

  - The key is non-required.

  - The key's value type is :term:`typing:consistent` with ``T``.

  - The key is not in ``S``.

For type checking purposes, let ``extra_items`` be a non-required pseudo-item
when checking for assignability according to rules defined in the
:ref:`Read-only Items <typing:readonly>` section, with a new rule added in bold
text as follows:

    A TypedDict type ``B`` is :term:`typing:assignable` to a TypedDict type
    ``A`` if ``B`` is :term:`structurally <typing:structural>` assignable to
    ``A``. This is true if and only if all of the following are satisfied:

    * **[If no key with the same name can be found in ``B``, the 'extra_items'
      argument is considered the value type of the corresponding key.]**

    * For each item in ``A``, ``B`` has the corresponding key, unless the item in
      ``A`` is read-only, not required, and of top value type
      (``ReadOnly[NotRequired[object]]``).

    * For each item in ``A``, if ``B`` has the corresponding key, the corresponding
      value type in ``B`` is assignable to the value type in ``A``.

    * For each non-read-only item in ``A``, its value type is assignable to the
      corresponding value type in ``B``, and the corresponding key is not read-only
      in ``B``.

    * For each required key in ``A``, the corresponding key is required in ``B``.

    * For each non-required key in ``A``, if the item is not read-only in ``A``,
      the corresponding key is not required in ``B``.

The following examples illustrate these checks in action.

``extra_items`` puts various restrictions on additional items for assignability
checks::

    class Movie(TypedDict, extra_items=int | None):
        name: str

    class MovieDetails(TypedDict, extra_items=int | None):
        name: str
        year: NotRequired[int]

    details: MovieDetails = {"name": "Kill Bill Vol. 1", "year": 2003}
    movie: Movie = details  # Not OK. While 'int' is assignable to 'int | None',
                            # 'int | None' is not assignable to 'int'

    class MovieWithYear(TypedDict, extra_items=int | None):
        name: str
        year: int | None

    details: MovieWithYear = {"name": "Kill Bill Vol. 1", "year": 2003}
    movie: Movie = details  # Not OK. 'year' is not required in 'Movie',
                            # but it is required in 'MovieWithYear'

where ``MovieWithYear`` (B) is not assignable to ``Movie`` (A)
according to this rule:

    * For each non-required key in ``A``, if the item is not read-only in ``A``,
      the corresponding key is not required in ``B``.

When ``extra_items`` is specified to be read-only on a TypedDict type, it is
possible for an item to have a :term:`narrower <typing:narrow>` type than the
``extra_items`` argument::

    class Movie(TypedDict, extra_items=ReadOnly[str | int]):
        name: str

    class MovieDetails(TypedDict, extra_items=int):
        name: str
        year: NotRequired[int]

    details: MovieDetails = {"name": "Kill Bill Vol. 2", "year": 2004}
    movie: Movie = details  # OK. 'int' is assignable to 'str | int'.

This behaves the same way as if ``year: ReadOnly[str | int]`` is an item
explicitly defined in ``Movie``.

``extra_items`` as a pseudo-item follows the same rules that other items have,
so when both TypedDicts types specify ``extra_items``, this check is naturally
enforced::

    class MovieExtraInt(TypedDict, extra_items=int):
        name: str

    class MovieExtraStr(TypedDict, extra_items=str):
        name: str

    extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
    extra_str: MovieExtraStr = {"name": "No Country for Old Men", "description": ""}
    extra_int = extra_str  # Not OK. 'str' is not assignable to extra items type 'int'
    extra_str = extra_int  # Not OK. 'int' is not assignable to extra items type 'str'

A non-closed TypedDict type implicitly allows non-required extra keys of value
type ``ReadOnly[object]``. Applying the assignability rules between this type
and a closed TypedDict type is allowed::

    class MovieNotClosed(TypedDict):
        name: str

    extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
    not_closed: MovieNotClosed = {"name": "No Country for Old Men"}
    extra_int = not_closed  # Not OK.
                            # 'extra_items=ReadOnly[object]' implicitly on 'MovieNotClosed'
                            # is not assignable to with 'extra_items=int'
    not_closed = extra_int  # OK

Interaction with Constructors
-----------------------------

TypedDicts that allow extra items of type ``T`` also allow arbitrary keyword
arguments of this type when constructed by calling the class object::

    class NonClosedMovie(TypedDict):
        name: str

    NonClosedMovie(name="No Country for Old Men")  # OK
    NonClosedMovie(name="No Country for Old Men", year=2007)  # Not OK. Unrecognized item

    class ExtraMovie(TypedDict, extra_items=int):
        name: str

    ExtraMovie(name="No Country for Old Men")  # OK
    ExtraMovie(name="No Country for Old Men", year=2007)  # OK
    ExtraMovie(
        name="No Country for Old Men",
        language="English",
    )  # Not OK. Wrong type for extra item 'language'

    # This implies 'extra_items=Never',
    # so extra keyword arguments would produce an error
    class ClosedMovie(TypedDict, closed=True):
        name: str

    ClosedMovie(name="No Country for Old Men")  # OK
    ClosedMovie(
        name="No Country for Old Men",
        year=2007,
    )  # Not OK. Extra items not allowed

Supported and Unsupported Operations
------------------------------------

This statement from the `typing spec
<https://typing.python.org/en/latest/spec/typeddict.html#supported-and-unsupported-operations>`__
still holds true.

    Operations with arbitrary str keys (instead of string literals or other
    expressions with known string values) should generally be rejected.

Operations that already apply to ``NotRequired`` items should generally also
apply to extra items, following the same rationale from the `typing spec
<https://typing.python.org/en/latest/spec/typeddict.html#supported-and-unsupported-operations>`__:

    The exact type checking rules are up to each type checker to decide. In some
    cases potentially unsafe operations may be accepted if the alternative is to
    generate false positive errors for idiomatic code.

Some operations, including indexed accesses and assignments with arbitrary str keys,
may be allowed due to the TypedDict being :term:`typing:assignable` to
``Mapping[str, VT]`` or ``dict[str, VT]``. The two following sections will expand
on that.

Interaction with Mapping[str, VT]
---------------------------------

A TypedDict type is :term:`typing:assignable` to a type of the form ``Mapping[str, VT]``
when all value types of the items in the TypedDict
are assignable to ``VT``. For the purpose of this rule, a
TypedDict that does not have ``extra_items=`` or ``closed=`` set is considered
to have an item with a value of type ``ReadOnly[object]``. This extends the
current assignability rule from the `typing spec
<https://typing.python.org/en/latest/spec/typeddict.html#assignability>`__.

For example::

    class MovieExtraStr(TypedDict, extra_items=str):
        name: str

    extra_str: MovieExtraStr = {"name": "Blade Runner", "summary": ""}
    str_mapping: Mapping[str, str] = extra_str  # OK

    class MovieExtraInt(TypedDict, extra_items=int):
        name: str

    extra_int: MovieExtraInt = {"name": "Blade Runner", "year": 1982}
    int_mapping: Mapping[str, int] = extra_int  # Not OK. 'int | str' is not assignable with 'int'
    int_str_mapping: Mapping[str, int | str] = extra_int  # OK

Type checkers should infer the precise signatures of ``values()`` and ``items()``
on such TypedDict types::

    def foo(movie: MovieExtraInt) -> None:
        reveal_type(movie.items())  # Revealed type is 'dict_items[str, str | int]'
        reveal_type(movie.values())  # Revealed type is 'dict_values[str, str | int]'

By extension of this assignability rule, type checkers may allow indexed accesses
with arbitrary str keys when ``extra_items`` or ``closed=True`` is specified.
For example::

    def bar(movie: MovieExtraInt, key: str) -> None:
        reveal_type(movie[key])  # Revealed type is 'str | int'

.. _pep728-type-narrowing:

Defining the type narrowing behavior for TypedDict is out-of-scope for this PEP.
This leaves flexibility for a type checker to be more/less restrictive about
indexed accesses with arbitrary str keys. For example, a type checker may opt
for more restriction by requiring an explicit ``'x' in d`` check.

Interaction with dict[str, VT]
------------------------------

Because the presence of ``extra_items`` on a closed TypedDict type
prohibits additional required keys in its :term:`typing:structural`
:term:`subtypes <subtype>`, we can determine if the TypedDict type and
its structural subtypes will ever have any required key during static analysis.

The TypedDict type is :term:`typing:assignable` to ``dict[str, VT]`` if all
items on the TypedDict type satisfy the following conditions:

- The value type of the item is :term:`typing:consistent` with ``VT``.

- The item is not read-only.

- The item is not required.

For example::

    class IntDict(TypedDict, extra_items=int):
        pass

    class IntDictWithNum(IntDict):
        num: NotRequired[int]

    def f(x: IntDict) -> None:
        v: dict[str, int] = x  # OK
        v.clear()  # OK

    not_required_num_dict: IntDictWithNum = {"num": 1, "bar": 2}
    regular_dict: dict[str, int] = not_required_num_dict  # OK
    f(not_required_num_dict)  # OK

In this case, methods that are previously unavailable on a TypedDict are allowed,
with signatures matching ``dict[str, VT]``
(e.g.: ``__setitem__(self, key: str, value: VT) -> None``)::

    not_required_num_dict.clear()  # OK

    reveal_type(not_required_num_dict.popitem())  # OK. Revealed type is 'tuple[str, int]'

    def f(not_required_num_dict: IntDictWithNum, key: str):
        not_required_num_dict[key] = 42  # OK
        del not_required_num_dict[key]  # OK

:ref:`Notes on indexed accesses <pep728-type-narrowing>` from the previous section
still apply.

``dict[str, VT]`` is not assignable to a TypedDict type,
because such dict can be a subtype of dict::

    class CustomDict(dict[str, int]):
        pass

    def f(might_not_be_a_builtin_dict: dict[str, int]):
        int_dict: IntDict = might_not_be_a_builtin_dict # Not OK

    not_a_builtin_dict = CustomDict({"num": 1})
    f(not_a_builtin_dict)

Runtime behavior
----------------

At runtime, it is an error to pass both the ``closed`` and ``extra_items``
arguments in the same TypedDict definition, whether using the class syntax or
the functional syntax. For simplicity, the runtime does not check other invalid
combinations involving inheritance.

For introspection, the ``closed`` and ``extra_items`` arguments are mapped to
two new attributes on the resulting TypedDict object: ``__closed__`` and
``__extra_items__``. These attributes reflect exactly what was passed to the
TypedDict constructor, without considering superclasses.

If ``closed`` is not passed, the value of ``__closed__`` is None. If ``extra_items``
is not passed, the value of ``__extra_items__`` is the new sentinel object
``typing.NoExtraItems``. (It cannot be ``None``, because ``extra_items=None`` is a
valid definition that indicates all extra items must be ``None``.)

How to Teach This
=================

The new features introduced in this PEP can be taught together with the concept
of inheritance as it applies to ``TypedDict``. A possible outline could be:

- Basics of ``TypedDict``: a ``dict`` with a fixed set of keys and value types.
- ``NotRequired``, ``Required``, and ``total=False``: keys that may be missing.
- ``ReadOnly``: keys that cannot be modified.
- Inheritance: subclasses can add new keys. As a corollary, a value of a ``TypedDict``
  type may contain additional keys at runtime that are not specified in the type.
- ``closed=True``: disallowing additional keys and restricting inheritance.
- ``extra_items=VT``: allowing additional keys with a specified value type.

The concept of a closed ``TypedDict`` should also be cross-referenced in documentation
for related concepts. For example, type narrowing with the ``in`` operator works
differently, perhaps more intuitively, with closed ``TypedDict`` types. In addition, when ``Unpack``
is used for keyword arguments, a closed ``TypedDict`` can be useful to restrict the
allowed keyword arguments.

Backwards Compatibility
=======================

Because ``extra_items`` is an opt-in feature, no existing codebase will break
due to this change.

Note that ``closed`` and ``extra_items`` as keyword arguments do not collide
with other keys when using something like
``TD = TypedDict("TD", foo=str, bar=int)``, because this syntax has already
been removed in Python 3.13.

Because this is a type-checking feature, it can be made available to older
versions as long as the type checker supports it.

Rejected Ideas
==============

Use ``@final`` instead of  ``closed`` Class Parameter
-----------------------------------------------------

This was discussed `here <https://github.com/python/mypy/issues/7981>`__.

Quoting a relevant `comment
<https://github.com/python/mypy/issues/7981#issuecomment-2080161813>`__
from Eric Traut:

    The @final class decorator indicates that a class cannot be subclassed. This
    makes sense for classes that define nominal types. However, TypedDict is a
    structural type, similar to a Protocol. That means two TypedDict classes
    with different names but the same field definitions are equivalent types.
    Their names and hierarchies don't matter for determining type consistency.
    For that reason, @final has no impact on a TypedDict type consistency rules,
    nor should it change the behavior of items or values.

Use a Special ``__extra_items__`` Key with the ``closed`` Class Parameter
-------------------------------------------------------------------------

In an earlier revision of this proposal, we discussed an approach that would
utilize ``__extra_items__``'s value type to specify the type of extra items
accepted, like so::

    class IntDict(TypedDict, closed=True):
        __extra_items__: int

where ``closed=True`` is required for ``__extra_items__`` to be treated
specially, to avoid key collision.

Some members of the community concern about the elegance of the syntax.
Practically, the key collision with a regular key can be mitigated with
workarounds, but since using a reserved key is central to this proposal,
there are limited ways forward to address the concerns.

Support a New Syntax of Specifying Keys
---------------------------------------

By introducing a new syntax that allows specifying string keys, we could
deprecate the functional syntax of defining TypedDict types and address the
key conflict issues if we decide to reserve a special key to type extra items.

For example::

    class Foo(TypedDict):
        name: str  # Regular item
        _: bool    # Type of extra items
        __items__ = {
            "_": int,   # Literal "_" as a key
            "class": str,  # Keyword as a key
            "tricky.name?": float,  # Arbitrary str key
        }

This was proposed `here by Jukka
<https://discuss.python.org/t/pep-728-typeddict-with-typed-extra-items/45443/115>`__.
The ``'_'`` key is chosen for not needing to invent a new name, and its
similarity with the match statement.

This will allow us to deprecate the functional syntax of defining TypedDict
types altogether, but there are some disadvantages. `For example
<https://github.com/python/peps/pull/4066#discussion_r1806986861>`__:

- It's less apparent to a reader that ``_: bool`` makes the TypedDict
  special, relative to adding a class argument like ``extra_items=bool``.

- It's backwards incompatible with existing TypedDicts using the
  ``_: bool`` key. While such users have a way to get around the issue,
  it's still a problem for them if they upgrade Python (or
  typing-extensions).

- The types don't appear in an annotation context, so their evaluation will
  not be deferred.

Allowing Extra Items without Specifying the Type
------------------------------------------------

``extra=True`` was originally proposed for defining a TypedDict that accepts
extra items regardless of the type, like how ``total=True`` works::

    class ExtraDict(TypedDict, extra=True):
        pass

Because it did not offer a way to specify the type of the extra items, the type
checkers will need to assume that the type of the extra items is ``Any``, which
compromises type safety. Furthermore, the current behavior of TypedDict already
allows untyped extra items to be present in runtime, due to
:term:`typing:structural` :term:`assignability <typing:assignable>`.
``closed=True`` plays a similar role in the current proposal.

Support Extra Items with Intersection
-------------------------------------

Supporting intersections in Python's type system requires a lot of careful
consideration, and it can take a long time for the community to reach a
consensus on a reasonable design.

Ideally, extra items in TypedDict should not be blocked by work on
intersections, nor does it necessarily need to be supported through
intersections.

Moreover, the intersection between ``Mapping[...]`` and ``TypedDict`` is not
equivalent to a TypedDict type with the proposed ``extra_items`` special
item, as the value type of all known items in ``TypedDict`` needs to satisfy the
is-subtype-of relation with the value type of ``Mapping[...]``.

Requiring Type Compatibility of the Known Items with ``extra_items``
------------------------------------------------------------------------

``extra_items`` restricts the value type for keys that are *unknown* to the
TypedDict type. So the value type of any *known* item is not necessarily
assignable to ``extra_items``, and ``extra_items`` is
not necessarily assignable to the value types of all known items.

This differs from TypeScript's `Index Signatures
<https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures>`__
syntax, which requires all properties' types to match the string index's type.
For example:

.. code-block:: typescript

    interface MovieWithExtraNumber {
        name: string // Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
        [index: string]: number
    }

    interface MovieWithExtraNumberOrString {
        name: string // OK
        [index: string]: number | string
    }

While this restriction allows for sound indexed accesses with arbitrary keys,
it comes with usability limitations discussed in `TypeScript's issue tracker
<https://github.com/microsoft/TypeScript/issues/17867>`__.
A suggestion was to allow excluding the defined keys from the index signature,
to define a type like ``MovieWithExtraNumber``. This probably involves
subtraction types, which is beyond the scope of this PEP.

Reference Implementation
========================

This is supported in `pyright 1.1.386
<https://github.com/microsoft/pyright/releases/tag/1.1.386>`_, and an earlier
revision is supported in `pyanalyze
0.12.0 <https://github.com/quora/pyanalyze/releases/tag/v0.12.0>`_.

This is also supported in `typing-extensions 4.13.0
<https://pypi.org/project/typing-extensions/4.13.0/>`_.

Acknowledgments
===============

Thanks to Jelle Zijlstra for sponsoring this PEP and providing review feedback,
Eric Traut who `proposed the original design
<https://mail.python.org/archives/list/typing-sig@python.org/message/3Z72OQWVTOVS6UYUUCCII2UZN56PV5II/>`__
this PEP iterates on, and Alice Purcell for offering their perspective as the
author of :pep:`705`.

Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.
