|
| 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