Skip to content

Commit 075c04d

Browse files
ViicosAA-TurnerJelleZijlstra
authored
PEP 764: Inlined typed dictionaries (#4082)
Co-authored-by: Adam Turner <[email protected]> Co-authored-by: Jelle Zijlstra <[email protected]>
1 parent 406a4ad commit 075c04d

File tree

3 files changed

+341
-5
lines changed

3 files changed

+341
-5
lines changed

.github/CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ peps/pep-0760.rst @pablogsal @brettcannon
642642
peps/pep-0761.rst @sethmlarson @hugovk
643643
peps/pep-0762.rst @pablogsal @ambv @lysnikolaou @emilyemorehouse
644644
peps/pep-0763.rst @dstufft
645+
peps/pep-0764.rst @JelleZijlstra
645646
peps/pep-0765.rst @iritkatriel @ncoghlan
646647
peps/pep-0766.rst @warsaw
647648
peps/pep-0767.rst @carljm

peps/conf.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,18 @@
6464
nitpick_ignore.append(("c:identifier", name))
6565
del role, name
6666

67-
# Intersphinx configuration
67+
# Intersphinx configuration (keep this in alphabetical order)
6868
intersphinx_mapping = {
69-
"python": ("https://docs.python.org/3/", None),
70-
"packaging": ("https://packaging.python.org/en/latest/", None),
71-
"typing": ("https://typing.readthedocs.io/en/latest/", None),
72-
"trio": ("https://trio.readthedocs.io/en/latest/", None),
7369
"devguide": ("https://devguide.python.org/", None),
70+
"mypy": ("https://mypy.readthedocs.io/en/latest/", None),
71+
"packaging": ("https://packaging.python.org/en/latest/", None),
7472
"py3.11": ("https://docs.python.org/3.11/", None),
7573
"py3.12": ("https://docs.python.org/3.12/", None),
7674
"py3.13": ("https://docs.python.org/3.13/", None),
7775
"py3.14": ("https://docs.python.org/3.14/", None),
76+
"python": ("https://docs.python.org/3/", None),
77+
"trio": ("https://trio.readthedocs.io/en/latest/", None),
78+
"typing": ("https://typing.readthedocs.io/en/latest/", None),
7879
}
7980
intersphinx_disabled_reftypes = []
8081

peps/pep-0764.rst

+334
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
PEP: 764
2+
Title: Inlined typed dictionaries
3+
Author: Victorien Plot <[email protected]>
4+
Sponsor: Eric Traut <erictr at microsoft.com>
5+
Status: Draft
6+
Type: Standards Track
7+
Topic: Typing
8+
Created: 25-Oct-2024
9+
Python-Version: 3.14
10+
11+
12+
Abstract
13+
========
14+
15+
:pep:`589` defines a :ref:`class-based <typing:typeddict-class-based-syntax>`
16+
and a :ref:`functional syntax <typing:typeddict-functional-syntax>` to create
17+
typed dictionaries. In both scenarios, it requires defining a class or
18+
assigning to a value. In some situations, this can add unnecessary
19+
boilerplate, especially if the typed dictionary is only used once.
20+
21+
This PEP proposes the addition of a new inlined syntax, by subscripting the
22+
:class:`~typing.TypedDict` type::
23+
24+
from typing import TypedDict
25+
26+
def get_movie() -> TypedDict[{'name': str, 'year': int}]:
27+
return {
28+
'name': 'Blade Runner',
29+
'year': 1982,
30+
}
31+
32+
Motivation
33+
==========
34+
35+
Python dictionaries are an essential data structure of the language. Many
36+
times, it is used to return or accept structured data in functions. However,
37+
it can get tedious to define :class:`~typing.TypedDict` classes:
38+
39+
* A typed dictionary requires a name, which might not be relevant.
40+
* Nested dictionaries require more than one class definition.
41+
42+
Taking a simple function returning some nested structured data as an example::
43+
44+
from typing import TypedDict
45+
46+
class ProductionCompany(TypedDict):
47+
name: str
48+
location: str
49+
50+
class Movie(TypedDict):
51+
name: str
52+
year: int
53+
production: ProductionCompany
54+
55+
56+
def get_movie() -> Movie:
57+
return {
58+
'name': 'Blade Runner',
59+
'year': 1982,
60+
'production': {
61+
'name': 'Warner Bros.',
62+
'location': 'California',
63+
}
64+
}
65+
66+
67+
Rationale
68+
=========
69+
70+
The new inlined syntax can be used to resolve these problems::
71+
72+
def get_movie() -> TypedDict[{'name': str, 'year': int, 'production': TypedDict[{'name': str, 'location': str}]}]:
73+
...
74+
75+
It is recommended to *only* make use of inlined typed dictionaries when the
76+
structured data isn't too large, as this can quickly become hard to read.
77+
78+
While less useful (as the functional or even the class-based syntax can be
79+
used), inlined typed dictionaries can be assigned to a variable, as an alias::
80+
81+
InlinedTD = TypedDict[{'name': str}]
82+
83+
def get_movie() -> InlinedTD:
84+
...
85+
86+
87+
Specification
88+
=============
89+
90+
The :class:`~typing.TypedDict` class is made subscriptable, and accepts a
91+
single type argument which must be a :class:`dict`, following the same
92+
semantics as the :ref:`functional syntax <typing:typeddict-functional-syntax>`
93+
(the dictionary keys are strings representing the field names, and values are
94+
valid :ref:`annotation expressions <typing:annotation-expression>`). Only the
95+
comma-separated list of ``key: value`` pairs within braces constructor
96+
(``{k: <type>}``) is allowed, and should be specified directly as the type
97+
argument (i.e. it is not allowed to use a variable which was previously
98+
assigned a :class:`dict` instance).
99+
100+
Inlined typed dictionaries can be referred to as *anonymous*, meaning they
101+
don't have a name (see the `runtime behavior <Runtime behavior>`_
102+
section).
103+
104+
It is possible to define a nested inlined dictionary::
105+
106+
Movie = TypedDict[{'name': str, 'production': TypedDict[{'location': str}]}]
107+
108+
# Note that the following is invalid as per the updated `type_expression` grammar:
109+
Movie = TypedDict[{'name': str, 'production': {'location': str}}]
110+
111+
Although it is not possible to specify any class arguments such as ``total``,
112+
any :external+typing:term:`type qualifier` can be used for individual fields::
113+
114+
Movie = TypedDict[{'name': NotRequired[str], 'year': ReadOnly[int]}]
115+
116+
Inlined typed dictionaries are implicitly *total*, meaning all keys must be
117+
present. Using the :data:`~typing.Required` type qualifier is thus redundant.
118+
119+
Type variables are allowed in inlined typed dictionaries, provided that they
120+
are bound to some outer scope::
121+
122+
class C[T]:
123+
inlined_td: TypedDict[{'name': T}] # OK, `T` is scoped to the class `C`.
124+
125+
reveal_type(C[int]().inlined_td['name']) # Revealed type is 'int'
126+
127+
128+
def fn[T](arg: T) -> TypedDict[{'name': T}]: ... # OK: `T` is scoped to the function `fn`.
129+
130+
reveal_type(fn('a')['name']) # Revealed type is 'str'
131+
132+
133+
type InlinedTD[T] = TypedDict[{'name': T}] # OK, `T` is scoped to the type alias.
134+
135+
136+
T = TypeVar('T')
137+
138+
InlinedTD = TypedDict[{'name': T}] # Not OK, `T` refers to a type variable that is not bound to any scope.
139+
140+
Typing specification changes
141+
----------------------------
142+
143+
The inlined typed dictionary adds a new kind of
144+
:external+typing:term:`type expression`. As such, the
145+
:external+typing:token:`~expression-grammar:type_expression` production will
146+
be updated to include the inlined syntax:
147+
148+
.. productionlist:: inlined-typed-dictionaries-grammar
149+
new-type_expression: `~expression-grammar:type_expression`
150+
: | <TypedDict> '[' '{' (string: ':' `~expression-grammar:annotation_expression` ',')* '}' ']'
151+
: (where string is any string literal)
152+
153+
Runtime behavior
154+
----------------
155+
156+
Although :class:`~typing.TypedDict` is commonly referred as a class, it is
157+
implemented as a function at runtime. To be made subscriptable, it will be
158+
changed to be a class.
159+
160+
Creating an inlined typed dictionary results in a new class, so ``T1`` and
161+
``T2`` are of the same type::
162+
163+
from typing import TypedDict
164+
165+
T1 = TypedDict('T1', {'a': int})
166+
T2 = TypedDict[{'a': int}]
167+
168+
As inlined typed dictionaries are are meant to be *anonymous*, their
169+
:attr:`~type.__name__` attribute will be set to an empty string.
170+
171+
Backwards Compatibility
172+
=======================
173+
174+
This PEP does not bring any backwards incompatible changes.
175+
176+
177+
Security Implications
178+
=====================
179+
180+
There are no known security consequences arising from this PEP.
181+
182+
183+
How to Teach This
184+
=================
185+
186+
The new inlined syntax will be documented both in the :mod:`typing` module
187+
documentation and the :ref:`typing specification <typing:typed-dictionaries>`.
188+
189+
As mentioned in the `Rationale`_, it should be mentioned that inlined typed
190+
dictionaries should be used for small structured data to not hurt readability.
191+
192+
193+
Reference Implementation
194+
========================
195+
196+
Mypy supports a similar syntax as an :option:`experimental feature <mypy:mypy.--enable-incomplete-feature>`::
197+
198+
def test_values() -> {"int": int, "str": str}:
199+
return {"int": 42, "str": "test"}
200+
201+
Pyright added support for the new syntax in version `1.1.387`_.
202+
203+
.. _1.1.387: https://github.com/microsoft/pyright/releases/tag/1.1.387
204+
205+
Runtime implementation
206+
----------------------
207+
208+
A draft implementation is available `here <https://github.com/Viicos/cpython/commit/49e5a83f>`_.
209+
210+
211+
Rejected Ideas
212+
==============
213+
214+
Using the functional syntax in annotations
215+
------------------------------------------
216+
217+
The alternative functional syntax could be used as an annotation directly::
218+
219+
def get_movie() -> TypedDict('Movie', {'title': str}): ...
220+
221+
However, call expressions are currently unsupported in such a context for
222+
various reasons (expensive to process, evaluating them is not standardized).
223+
224+
This would also require a name which is sometimes not relevant.
225+
226+
Using ``dict`` with a single type argument
227+
------------------------------------------
228+
229+
We could reuse :class:`dict` with a single type argument to express the same
230+
concept::
231+
232+
def get_movie() -> dict[{'title': str}]: ...
233+
234+
While this would avoid having to import :class:`~typing.TypedDict` from
235+
:mod:`typing`, this solution has several downsides:
236+
237+
* For type checkers, :class:`dict` is a regular class with two type variables.
238+
Allowing :class:`dict` to be parametrized with a single type argument would
239+
require special casing from type checkers, as there is no way to express
240+
parametrization overloads. On the other hand, :class:`~typing.TypedDict` is
241+
already a :term:`special form <typing:special form>`.
242+
243+
* If future work extends what inlined typed dictionaries can do, we don't have
244+
to worry about impact of sharing the symbol with :class:`dict`.
245+
246+
Using a simple dictionary
247+
-------------------------
248+
249+
Instead of subscripting the :class:`~typing.TypedDict` class, a plain
250+
dictionary could be used as an annotation::
251+
252+
def get_movie() -> {'title': str}: ...
253+
254+
However, :pep:`584` added union operators on dictionaries and :pep:`604`
255+
introduced :ref:`union types <python:types-union>`. Both features make use of
256+
the :ref:`bitwise or (|) <python:bitwise>` operator, making the following use
257+
cases incompatible, especially for runtime introspection::
258+
259+
# Dictionaries are merged:
260+
def fn() -> {'a': int} | {'b': str}: ...
261+
262+
# Raises a type error at runtime:
263+
def fn() -> {'a': int} | int: ...
264+
265+
Open Issues
266+
===========
267+
268+
Subclassing an inlined typed dictionary
269+
---------------------------------------
270+
271+
Should we allow the following?::
272+
273+
from typing import TypedDict
274+
275+
InlinedTD = TypedDict[{'a': int}]
276+
277+
278+
class SubTD(InlinedTD):
279+
pass
280+
281+
What about defining an inlined typed dictionay extending another typed
282+
dictionary?::
283+
284+
InlinedBase = TypedDict[{'a': int}]
285+
286+
Inlined = TypedDict[InlinedBase, {'b': int}]
287+
288+
Using ``typing.Dict`` with a single argument
289+
--------------------------------------------
290+
291+
While using :class:`dict` isn't ideal, we could make use of
292+
:class:`typing.Dict` with a single argument::
293+
294+
def get_movie() -> Dict[{'title': str}]: ...
295+
296+
It is less verbose, doesn't have the baggage of :class:`dict`, and is
297+
already defined as some kind of special form.
298+
299+
However, it is currently marked as deprecated (although not scheduled for
300+
removal), so it might be confusing to undeprecate it.
301+
302+
This would also set a precedent on typing constructs being parametrizable
303+
with a different number of type arguments.
304+
305+
Should inlined typed dictionaries be proper classes?
306+
----------------------------------------------------
307+
308+
The PEP currently defines inlined typed dictionaries as type objects, to be in
309+
line with the existing syntaxes. To work around the fact that they don't have
310+
a name, their :attr:`~type.__name__` attribute is set to an empty string.
311+
312+
This is somewhat arbitrary, and an alternative name could be used as well
313+
(e.g. ``'<TypedDict>'``).
314+
315+
Alternatively, inlined typed dictionaries could be defined as instances of a
316+
new (internal) typing class, e.g. :class:`!typing._InlinedTypedDict`. While
317+
this solves the naming issue, it requires extra logic in the runtime
318+
implementation to provide the introspection attributes (such as
319+
:attr:`~typing.TypedDict.__total__`), and tools relying on runtime
320+
introspection would have to add proper support for this new type.
321+
322+
Inlined typed dictionaries and extra items
323+
------------------------------------------
324+
325+
:pep:`728` introduces the concept of *closed* type dictionaries. Inlined
326+
typed dictionaries should probably be implicitly *closed*, but it may be
327+
better to wait for :pep:`728` to be accepted first.
328+
329+
330+
Copyright
331+
=========
332+
333+
This document is placed in the public domain or under the
334+
CC0-1.0-Universal license, whichever is more permissive.

0 commit comments

Comments
 (0)