Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PEP 764: Inlined typed dictionaries #4082

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions peps/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
303 changes: 303 additions & 0 deletions peps/pep-0764.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
PEP: 764
Title: Inlined typed dictionaries
Author: Victorien Plot <[email protected]>
Sponsor: Eric Traut <erictr at microsoft.com>
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 <typing:typeddict-class-based-syntax>`
and a :ref:`functional syntax <typing:typeddict-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}]
Viicos marked this conversation as resolved.
Show resolved Hide resolved

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 <typing:typeddict-functional-syntax>`
(the dictionary keys are strings representing the field names, and values are
valid :ref:`annotation expressions <typing:annotation-expression>`).
Viicos marked this conversation as resolved.
Show resolved Hide resolved

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:
Viicos marked this conversation as resolved.
Show resolved Hide resolved
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]}]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder, is there a world in which eventually you could do something like:

Movie = Annotated[TypedDict[{'name': str, 'year': int}], Total(False)]

Or something like that for class args?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would require:

  • introducing a typing.Total class/function
  • setting a precedent regarding the meaning of Annotated metadata, which is currently ignored by type checkers.


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`
: | <TypedDict> '[' '{' (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 <typing:typed-dictionaries>`.

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 <mypy:mypy.--enable-incomplete-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 <https://github.com/Viicos/cpython/commit/49e5a83f>`_.


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 <typing: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 <python:types-union>`. Both features make use of
the :ref:`bitwise or (|) <python:bitwise>` 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}]: ...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I like the strict parametrization of TypedDict better, I feel like this would set an odd precedent and confuse some users.


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.