Skip to content

Commit 484c43d

Browse files
PEP 728: Improve specification (#4166)
- Specify that extra_items and closed are also supported with the functional syntax. - Rewrite the rules for `closed=True` and inheritance. I attempted to make `closed=True` exactly equivalent to `extra_items=Never` in terms of inheritance. The semantics as specified in the previous version of the PEP felt harder to understand and less consistent. - Fix some incorrect comments regarding expected type checker errors. - Clarify section on assignability with Mapping - Add section on runtime behavior. I tried to make the intended runtime behavior simple to implement and understand. This makes the runtime simpler but may make life more complicated for tools consuming the metadata. - Fix typos and trailing whitespace
1 parent d98e3fa commit 484c43d

File tree

1 file changed

+78
-51
lines changed

1 file changed

+78
-51
lines changed

peps/pep-0728.rst

Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Another possible use case for this is a sound way to
4848
class Movie(TypedDict):
4949
name: str
5050
director: str
51-
51+
5252
class Book(TypedDict):
5353
name: str
5454
author: str
@@ -194,12 +194,12 @@ to the ``extra_items`` argument. For example::
194194

195195
class Movie(TypedDict, extra_items=bool):
196196
name: str
197-
197+
198198
a: Movie = {"name": "Blade Runner", "novel_adaptation": True} # OK
199199
b: Movie = {
200200
"name": "Blade Runner",
201201
"year": 1982, # Not OK. 'int' is not assignable to 'bool'
202-
}
202+
}
203203

204204
Here, ``extra_items=bool`` specifies that items other than ``'name'``
205205
have a value type of ``bool`` and are non-required.
@@ -214,12 +214,12 @@ the ``extra_items`` argument::
214214
def f(movie: Movie) -> None:
215215
reveal_type(movie["name"]) # Revealed type is 'str'
216216
reveal_type(movie["novel_adaptation"]) # Revealed type is 'bool'
217-
217+
218218
``extra_items`` is inherited through subclassing::
219219

220220
class MovieBase(TypedDict, extra_items=int | None):
221221
name: str
222-
222+
223223
class Movie(MovieBase):
224224
year: int
225225

@@ -234,38 +234,46 @@ Here, ``'year'`` in ``a`` is an extra key defined on ``Movie`` whose value type
234234
is ``int``. ``'other_extra_key'`` in ``b`` is another extra key whose value type
235235
must be assignable to the value of ``extra_items`` defined on ``MovieBase``.
236236

237+
``extra_items`` is also supported with the functional syntax::
238+
239+
Movie = TypedDict("Movie", {"name": str}, extra_items=int | None)
240+
237241
The ``closed`` Class Parameter
238242
------------------------------
239243

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

244249
Similar to ``total``, only a literal ``True`` or ``False`` is supported as the
245-
value of the ``closed`` argument; ``closed`` is ``False`` by default, which
246-
preserves the previous TypedDict behavior.
250+
value of the ``closed`` argument. Type checkers should reject any non-literal value.
251+
252+
Passing ``closed=False`` explicitly requests the default TypedDict behavior,
253+
where arbitrary other keys may be present and subclasses may add arbitrary items.
254+
It is a type checker error to pass ``closed=False`` if a superclass has
255+
``closed=True`` or sets ``extra_items``.
247256

248-
The value of ``closed`` is not inherited through subclassing, but the
249-
implicitly set ``extra_items=Never`` is. It should be an error to use the
250-
default ``closed=False`` when subclassing a closed TypedDict type::
257+
If ``closed`` is not provided, the behavior is inherited from the superclass.
258+
If the superclass is TypedDict itself or the superclass does not have ``closed=True``
259+
or the ``extra_items`` parameter, the previous TypedDict behavior is preserved:
260+
arbitrary extra items are allowed. If the superclass has ``closed=True``, the
261+
child class is also closed.
251262

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

255-
class MovieA(BaseMovie): # Not OK. An explicit 'closed=True' is required
266+
class MovieA(BaseMovie): # OK, still closed
256267
pass
257268

258-
class MovieB(BaseMovie, closed=True): # OK
269+
class MovieB(BaseMovie, closed=True): # OK, but redundant
259270
pass
260271

261-
Setting both ``closed`` and ``extra_items`` when defining a TypedDict type
262-
should always be a runtime error::
263-
264-
class Person(TypedDict, closed=False, extra_items=bool): # Not OK. 'closed' and 'extra_items' are incompatible
265-
name: str
272+
class MovieC(BaseMovie, closed=False): # Type checker error
273+
pass
266274

267-
As a consequence of ``closed=True`` being equivalent to ``extra_items=Never``.
268-
The same rules that apply to ``extra_items=Never`` should also apply to
275+
As a consequence of ``closed=True`` being equivalent to ``extra_items=Never``,
276+
the same rules that apply to ``extra_items=Never`` also apply to
269277
``closed=True``. It is possible to use ``closed=True`` when subclassing if the
270278
``extra_items`` argument is a read-only type::
271279

@@ -275,7 +283,7 @@ The same rules that apply to ``extra_items=Never`` should also apply to
275283
class MovieClosed(Movie, closed=True): # OK
276284
pass
277285

278-
class MovieNever(Movie, extra_items=Never): # Not OK. 'closed=True' is preferred
286+
class MovieNever(Movie, extra_items=Never): # OK, but 'closed=True' is preferred
279287
pass
280288

281289
This will be further discussed in
@@ -286,6 +294,10 @@ is assumed to allow non-required extra items of value type ``ReadOnly[object]``
286294
during inheritance or assignability checks. This preserves the existing behavior
287295
of TypedDict.
288296

297+
``closed`` is also supported with the functional syntax::
298+
299+
Movie = TypedDict("Movie", {"name": str}, closed=True)
300+
289301
Interaction with Totality
290302
-------------------------
291303

@@ -315,7 +327,7 @@ function parameters still apply::
315327

316328
class Movie(TypedDict, extra_items=int):
317329
name: str
318-
330+
319331
def f(**kwargs: Unpack[Movie]) -> None: ...
320332

321333
# Should be equivalent to:
@@ -356,7 +368,7 @@ unless it is declared to be ``ReadOnly`` in the superclass::
356368

357369
class Parent(TypedDict, extra_items=int | None):
358370
pass
359-
371+
360372
class Child(Parent, extra_items=int): # Not OK. Like any other TypedDict item, extra_items's type cannot be changed
361373

362374
Second, ``extra_items=T`` effectively defines the value type of any unnamed
@@ -378,20 +390,17 @@ added in a subclass, all of the following conditions should apply:
378390

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

381-
- If ``extra_items`` is not overriden, the subclass inherits it as-is.
393+
- If ``extra_items`` is not overridden, the subclass inherits it as-is.
382394

383395
For example::
384396

385397
class MovieBase(TypedDict, extra_items=int | None):
386398
name: str
387-
388-
class AdaptedMovie(MovieBase): # Not OK. 'bool' is not assignable to 'int | None'
389-
adapted_from_novel: bool
390-
391-
class MovieRequiredYear(MovieBase): # Not OK. Required key 'year' is not known to 'Parent'
399+
400+
class MovieRequiredYear(MovieBase): # Not OK. Required key 'year' is not known to 'MovieBase'
392401
year: int | None
393402

394-
class MovieNotRequiredYear(MovieBase): # Not OK. 'int | None' is not assignable to 'int'
403+
class MovieNotRequiredYear(MovieBase): # Not OK. 'int | None' is not consistent with 'int'
395404
year: NotRequired[int]
396405

397406
class MovieWithYear(MovieBase): # OK
@@ -478,7 +487,7 @@ checks::
478487
class MovieDetails(TypedDict, extra_items=int | None):
479488
name: str
480489
year: NotRequired[int]
481-
490+
482491
details: MovieDetails = {"name": "Kill Bill Vol. 1", "year": 2003}
483492
movie: Movie = details # Not OK. While 'int' is assignable to 'int | None',
484493
# 'int | None' is not assignable to 'int'
@@ -502,7 +511,7 @@ possible for an item to have a :term:`narrower <typing:narrow>` type than the
502511

503512
class Movie(TypedDict, extra_items=ReadOnly[str | int]):
504513
name: str
505-
514+
506515
class MovieDetails(TypedDict, extra_items=int):
507516
name: str
508517
year: NotRequired[int]
@@ -522,19 +531,19 @@ enforced::
522531

523532
class MovieExtraStr(TypedDict, extra_items=str):
524533
name: str
525-
534+
526535
extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
527536
extra_str: MovieExtraStr = {"name": "No Country for Old Men", "description": ""}
528537
extra_int = extra_str # Not OK. 'str' is not assignable to extra items type 'int'
529538
extra_str = extra_int # Not OK. 'int' is not assignable to extra items type 'str'
530-
539+
531540
A non-closed TypedDict type implicitly allows non-required extra keys of value
532541
type ``ReadOnly[object]``. Applying the assignability rules between this type
533542
and a closed TypedDict type is allowed::
534543

535544
class MovieNotClosed(TypedDict):
536545
name: str
537-
546+
538547
extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
539548
not_closed: MovieNotClosed = {"name": "No Country for Old Men"}
540549
extra_int = not_closed # Not OK.
@@ -578,17 +587,13 @@ arguments of this type when constructed by calling the class object::
578587
Interaction with Mapping[KT, VT]
579588
--------------------------------
580589

581-
A TypedDict type can be assignable to ``Mapping[KT, VT]`` types other than
582-
``Mapping[str, object]`` as long as all value types of the items on the
583-
TypedDict type is :term:`typing:assignable` to ``VT``. This is an extension of this
590+
A TypedDict type is :term:`typing:assignable` to a type of the form ``Mapping[str, VT]``
591+
when all value types of the items in the TypedDict
592+
are assignable to ``VT``. For the purpose of this rule, a
593+
TypedDict that does not have ``extra_items=`` or ``closed=`` set is considered
594+
to have an item with a value of type ``object``. This extends the current
584595
assignability rule from the `typing spec
585-
<https://typing.readthedocs.io/en/latest/spec/typeddict.html#assignability>`__:
586-
587-
* A TypedDict with all ``int`` values is not :term:`typing:assignable` to
588-
``Mapping[str, int]``, since there may be additional non-``int`` values
589-
not visible through the type, due to :term:`typing:structural`
590-
assignability. These can be accessed using the ``values()`` and
591-
``items()`` methods in ``Mapping``,
596+
<https://typing.readthedocs.io/en/latest/spec/typeddict.html#assignability>`__.
592597

593598
For example::
594599

@@ -598,6 +603,10 @@ For example::
598603
extra_str: MovieExtraStr = {"name": "Blade Runner", "summary": ""}
599604
str_mapping: Mapping[str, str] = extra_str # OK
600605

606+
class MovieExtraInt(TypedDict, extra_items=int):
607+
name: str
608+
609+
extra_int: MovieExtraInt = {"name": "Blade Runner", "year": 1982}
601610
int_mapping: Mapping[str, int] = extra_int # Not OK. 'int | str' is not assignable with 'int'
602611
int_str_mapping: Mapping[str, int | str] = extra_int # OK
603612

@@ -611,7 +620,7 @@ and ``items()`` on such TypedDict types::
611620
Interaction with dict[KT, VT]
612621
-----------------------------
613622

614-
Note that because the presence of ``extra_items`` on a closed TypedDict type
623+
Because the presence of ``extra_items`` on a closed TypedDict type
615624
prohibits additional required keys in its :term:`typing:structural`
616625
:term:`typing:subtypes <subtype>`, we can determine if the TypedDict type and
617626
its structural subtypes will ever have any required key during static analysis.
@@ -636,8 +645,8 @@ For example::
636645
def f(x: IntDict) -> None:
637646
v: dict[str, int] = x # OK
638647
v.clear() # OK
639-
640-
not_required_num_dict: IntDictWithNum = {"num": 1, "bar": 2}
648+
649+
not_required_num_dict: IntDictWithNum = {"num": 1, "bar": 2}
641650
regular_dict: dict[str, int] = not_required_num_dict # OK
642651
f(not_required_num_dict) # OK
643652

@@ -652,10 +661,28 @@ because such dict can be a subtype of dict::
652661

653662
class CustomDict(dict[str, int]):
654663
pass
655-
664+
656665
not_a_regular_dict: CustomDict = {"num": 1}
657666
int_dict: IntDict = not_a_regular_dict # Not OK
658667

668+
Runtime behavior
669+
----------------
670+
671+
At runtime, it is an error to pass both the ``closed`` and ``extra_items``
672+
arguments in the same TypedDict definition, whether using the class syntax or
673+
the functional syntax. For simplicity, the runtime does not check other invalid
674+
combinations involving inheritance.
675+
676+
For introspection, the ``closed`` and ``extra_items`` arguments are mapped to
677+
two new attributes on the resulting TypedDict object: ``__closed__`` and
678+
``__extra_items__``. These attributes reflect exactly what was passed to the
679+
TypedDict constructor, without considering superclasses.
680+
681+
If ``closed`` is not passed, the value of ``__closed__`` is None. If ``extra_items``
682+
is not passed, the value of ``__extra_items__`` is the new sentinel object
683+
``typing.NoExtraItems``. (It cannot be ``None``, because ``extra_items=None`` is a
684+
valid definition that indicates all extra items must be ``None``.)
685+
659686
How to Teach This
660687
=================
661688

@@ -673,7 +700,7 @@ Because ``extra_items`` is an opt-in feature, no existing codebase will break
673700
due to this change.
674701

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

0 commit comments

Comments
 (0)