diff --git a/peps/conf.py b/peps/conf.py index f566a42ee53..60c0404fe69 100644 --- a/peps/conf.py +++ b/peps/conf.py @@ -70,6 +70,7 @@ "typing": ("https://typing.readthedocs.io/en/latest/", None), "trio": ("https://trio.readthedocs.io/en/latest/", None), "devguide": ("https://devguide.python.org/", None), + "mypy": ("https://mypy.readthedocs.io/en/latest/", None), "py3.11": ("https://docs.python.org/3.11/", None), "py3.12": ("https://docs.python.org/3.12/", None), "py3.13": ("https://docs.python.org/3.13/", None), diff --git a/peps/pep-0764.rst b/peps/pep-0764.rst new file mode 100644 index 00000000000..d74b1e65c80 --- /dev/null +++ b/peps/pep-0764.rst @@ -0,0 +1,303 @@ +PEP: 764 +Title: Inlined typed dictionaries +Author: Victorien Plot +Sponsor: Eric Traut +Status: Draft +Type: Standards Track +Topic: Typing +Created: 25-Oct-2024 +Python-Version: 3.14 + +.. highlight:: python + + +Abstract +======== + +:pep:`589` defines a :ref:`class-based ` +and a :ref:`functional syntax ` to create +typed dictionaries. In both scenarios, it requires defining a class or +assigning to a value. In some situations, this can add unnecessary boilerplate, +especially if the typed dictionary is only used once. + +This PEP proposes the addition of a new inlined syntax, by subscripting the +:class:`~typing.TypedDict` type:: + + from typing import TypedDict + + def get_movie() -> TypedDict[{'name': str, 'year': int}]: + return { + 'name': 'Blade Runner', + 'year': 1982, + } + +Motivation +========== + +Python dictionaries are an essential data structure of the language. Many +times, it is used to return or accept structured data in functions. However, +it can get tedious to define :class:`~typing.TypedDict` classes: + +* A typed dictionary requires a name, which might not be relevant. +* Nested dictionaries require more than one class definition. + +Taking a simple function returning some nested structured data as an example:: + + from typing import TypedDict + + class ProductionCompany(TypedDict): + name: str + location: str + + class Movie(TypedDict): + name: str + year: int + production: ProductionCompany + + + def get_movie() -> Movie: + return { + 'name': 'Blade Runner', + 'year': 1982, + 'production': { + 'name': 'Warner Bros.', + 'location': 'California', + } + } + + +Rationale +========= + +The new inlined syntax can be used to resolve these problems:: + + def get_movie() -> TypedDict[{'name': str, 'year': int, 'production': TypedDict[{'name': str, 'location': str}]}]: + ... + +It is recommended to *only* make use of inlined typed dictionaries when the +structured data isn't too large, as this can quickly become hard to read. + +While less useful (as the functional or even the class-based syntax can be +used), inlined typed dictionaries can be assigned to a variable, as an alias:: + + InlinedTD = TypedDict[{'name': str}] + + def get_movie() -> InlinedTD: + ... + + +Specification +============= + +The :class:`~typing.TypedDict` class is made subscriptable, and accepts a +single type argument which must be a :class:`dict`, following the same +semantics as the :ref:`functional syntax ` +(the dictionary keys are strings representing the field names, and values are +valid :ref:`annotation expressions `). + +Inlined typed dictionaries can be referred to as *anonymous*, meaning they +don't have a name. For this reason, their :attr:`~type.__name__` attribute +will be set to an empty string. + +It is possible to define a nested inlined dictionary:: + + Movie = TypedDict[{'name': str, 'production': TypedDict[{'location': str}]}] + + # Note that the following is invalid as per the updated `type_expression` grammar: + Movie = TypedDict[{'name': str, 'production': {'location': str}}] + +Although it is not possible to specify any class arguments such as ``total``, +any :external+typing:term:`type qualifier` can be used for individual fields:: + + Movie = TypedDict[{'name': NotRequired[str], 'year': ReadOnly[int]}] + +Inlined typed dictionaries are implicitly *total*, meaning all keys must be +present. Using the :data:`~typing.Required` type qualifier is thus redundant. + +Type variables are allowed in inlined typed dictionaries, provided that they +are bound to some outer scope:: + + class C[T]: + inlined_td: TypedDict[{'name': T}] # OK, `T` is scoped to the class `C`. + + reveal_type(C[int]().inlined_td['name']) # Revealed type is 'int' + + + def fn[T](arg: T) -> TypedDict[{'name': T}]: ... # OK: `T` is scoped to the function `fn`. + + reveal_type(fn('a')['name']) # Revealed type is 'str' + + + type InlinedTD[T] = TypedDict[{'name': T}] # OK, `T` is scoped to the type alias. + + + T = TypeVar('T') + + InlinedTD = TypedDict[{'name': T}] # Not OK, `T` refers to a type variable that is not bound to any scope. + +**TODO** closed + +Typing specification changes +---------------------------- + +The inlined typed dictionary adds a new kind of +:external+typing:term:`type expression`. As such, the +:external+typing:token:`~expression-grammar:type_expression` production will +be updated to include the inlined syntax: + +.. productionlist:: inlined-typed-dictionaries-grammar + new-type_expression: `~expression-grammar:type_expression` + : | '[' '{' (string: ':' `~expression-grammar:annotation_expression` ',')* '}' ']' + : (where string is any string literal) + +Runtime behavior +---------------- + +Although :class:`~typing.TypedDict` is commonly referred as a class, it is +implemented as a function at runtime. To be made subscriptable, it will be +changed to be a class. + +Creating an inlined typed dictionary results in a new class, so ``T1`` and +``T2`` are the same type (apart from the different :attr:`~type.__name__`):: + + from typing import TypedDict + + T1 = TypedDict('T1', {'a': int}) + T2 = TypedDict[{'a': int}] + + +Backwards Compatibility +======================= + +Apart from the :class:`~typing.TypedDict` internal implementation change, this +PEP does not bring any backwards incompatible changes. + + +Security Implications +===================== + +There are no known security consequences arising from this PEP. + + +How to Teach This +================= + +The new inlined syntax will be documented both in the :mod:`typing` module +documentation and the :ref:`typing specification `. + +As mentioned in the `Rationale`_, it should be mentioned that inlined typed +dictionaries should be used for small structured data to not hurt readability. + + +Reference Implementation +======================== + +Mypy supports a similar syntax as an :option:`experimental feature `:: + + def test_values() -> {"int": int, "str": str}: + return {"int": 42, "str": "test"} + +Pyright added support for the new syntax in version `1.1.387`_. + +.. _1.1.387: https://github.com/microsoft/pyright/releases/tag/1.1.387 + +Runtime implementation +---------------------- + +A draft implementation is available `here `_. + + +Rejected Ideas +============== + +Using the functional syntax in annotations +------------------------------------------ + +The alternative functional syntax could be used as an annotation directly:: + + def get_movie() -> TypedDict('Movie', {'title': str}): ... + +However, call expressions are currently unsupported in such a context for +various reasons (expensive to process, evaluating them is not standardized). + +This would also require a name which is sometimes not relevant. + +Using ``dict`` with a single type argument +------------------------------------------ + +We could reuse :class:`dict` with a single type argument to express the same +concept:: + + def get_movie() -> dict[{'title': str}]: ... + +While this would avoid having to import :class:`~typing.TypedDict` from +:mod:`typing`, this solution has several downsides: + +* For type checkers, :class:`dict` is a regular class with two type variables. + Allowing :class:`dict` to be parametrized with a single type argument would + require special casing from type checkers, as there is no way to express + parametrization overloads. On ther other hand, :class:`~typing.TypedDict` is + already a :term:`special form `. + +* If future work extends what inlined typed dictionaries can do, we don't have + to worry about impact of sharing the symbol with :class:`dict`. + +Using a simple dictionary +------------------------- + +Instead of subscripting the :class:`~typing.TypedDict` class, a plain +dictionary could be used as an annotation:: + + def get_movie() -> {'title': str}: ... + +However, :pep:`584` added union operators on dictionaries and :pep:`604` +introduced :ref:`union types `. Both features make use of +the :ref:`bitwise or (|) ` operator, making the following use +cases incompatible, especially for runtime introspection:: + + # Dictionaries are merged: + def fn() -> {'a': int} | {'b': str}: ... + + # Raises a type error at runtime: + def fn() -> {'a': int} | int: ... + +Open Issues +=========== + +Subclassing an inlined typed dictionary +--------------------------------------- + +Should we allow the following?:: + + from typing import TypedDict + + InlinedTD = TypedDict[{'a': int}] + + + class SubTD(InlinedTD): + pass + +Using ``typing.Dict`` with a single argument +-------------------------------------------- + +While using :class:`dict` isn't ideal, we could make use of +:class:`typing.Dict` with a single argument:: + + def get_movie() -> Dict[{'title': str}]: ... + +It is less verbose, doesn't have the baggage of :class:`dict`, +and is defined as some kind of special form (an alias to the built-in +``dict``). + +However, it is currently marked as deprecated (although not scheduled for +removal), so it might be confusing to undeprecate it. + +This would also set a precedent on typing constructs being parametrizable +with a different number of type arguments. + + +Copyright +========= + +This document is placed in the public domain or under the +CC0-1.0-Universal license, whichever is more permissive.