Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 764 – Inlined typed dictionaries

Author:
Victorien Plot <contact at vctrn.dev>
Sponsor:
Eric Traut <erictr at microsoft.com>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Topic:
Typing
Created:
25-Oct-2024
Python-Version:
3.14
Post-History:
29-Jan-2025

Table of Contents

Abstract

PEP 589 defines a class-based and a 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 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 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}]}]:
    ...

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 TypedDict special form is made subscriptable, and accepts a single type argument which must be a dict, following the same semantics as the functional syntax (the dictionary keys are strings representing the field names, and values are valid annotation expressions). Only the comma-separated list of key: value pairs within braces constructor ({k: <type>}) is allowed, and should be specified directly as the type argument (i.e. it is not allowed to use a variable which was previously assigned a dict instance).

Inlined typed dictionaries can be referred to as anonymous, meaning they don’t have a specific name (see the runtime behavior section).

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 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 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}]  # OK, same as the previous type alias, but using the old-style syntax.


def func():
    InlinedTD = TypedDict[{'name': T}]  # Not OK: `T` refers to a type variable that is not bound to the scope of `func`.

Typing specification changes

The inlined typed dictionary adds a new kind of type expression. As such, the type_expression production will be updated to include the inlined syntax:

new-type_expression ::=  type_expression
                         | <TypedDict> '[' '{' (string: ':' annotation_expression ',')* '}' ']'
                               (where string is any string literal)

Runtime behavior

Although 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 of the same type:

from typing import TypedDict

T1 = TypedDict('T1', {'a': int})
T2 = TypedDict[{'a': int}]

As inlined typed dictionaries are are meant to be anonymous, their __name__ attribute will be set to an empty string.

Backwards Compatibility

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 typing module documentation and the typing specification.

When complex dictionary structures are used, having everything defined on a single line can hurt readability. Code formatters can help by formatting the inlined typed dictionary across multiple lines:

def edit_movie(
    movie: TypedDict[{
        'name': str,
        'year': int,
        'production': TypedDict[{
            'location': str,
        }],
    }],
) -> None:
    ...

Reference Implementation

Mypy supports a similar syntax as an 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.

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 or typing.Dict with a single type argument

We could reuse dict or typing.Dict with a single type argument to express the same concept:

def get_movie() -> dict[{'title': str}]: ...

While this would avoid having to import TypedDict from typing, this solution has several downsides:

  • For type checkers, dict is a regular class with two type variables. Allowing 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 the other hand, TypedDict is already a 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 dict.
  • typing.Dict has been deprecated (although not planned for removal) by PEP 585. Having it used for a new typing feature would be confusing for users (and would require changes in code linters).

Using a simple dictionary

Instead of subscripting the 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 union types. Both features make use of the 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: ...

Extending other typed dictionaries

Several syntaxes could be used to have the ability to extend other typed dictionaries:

InlinedBase = TypedDict[{'a': int}]

Inlined = TypedDict[InlinedBase, {'b': int}]
# or, by providing a slice:
Inlined = TypedDict[{'b': int} : (InlinedBase,)]

As inlined typed dictionaries are meant to only support a subset of the existing syntax, adding this extension mechanism isn’t compelling enough to be supported, considering the added complexity.

If intersections were to be added into the type system, it could cover this use case.

Open Issues

Should inlined typed dictionaries be proper classes?

The PEP currently defines inlined typed dictionaries as type objects, to be in line with the existing syntaxes. To work around the fact that they don’t have a name, their __name__ attribute is set to an empty string.

This is somewhat arbitrary, and an alternative name could be used as well (e.g. '<TypedDict>').

Alternatively, inlined typed dictionaries could be defined as instances of a new (internal) typing class, e.g. typing._InlinedTypedDict. While this solves the naming issue, it requires extra logic in the runtime implementation to provide the introspection attributes (such as __total__), and tools relying on runtime introspection would have to add proper support for this new type.

Depending on the outcome of the runtime implementation, we can more or less easily allow extending inlined typed dictionaries:

InlinedTD = TypedDict[{'a': int}]

# If `InlinedTD` is a typing._InlinedTypedDict instance, this adds complexity:
class SubTD(InlinedTD):
    pass

Inlined typed dictionaries and extra items

PEP 728 introduces the concept of closed type dictionaries. If this PEP were to be accepted, inlined typed dictionaries will be closed by default. This means PEP 728 needs to be addressed first, so that this PEP can be updated accordingly.


Source: https://github.com/python/peps/blob/main/peps/pep-0764.rst

Last modified: 2025-03-04 15:00:34 GMT