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

Python Enhancement Proposals

PEP 849 – More Expressive Type Expressions

PEP 849 – More Expressive Type Expressions

Author:
Imogen Hergeth <python at imogen.tech>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Topic:
Typing
Created:
20-Sep-2026
Python-Version:
3.16
Post-History:
05-Nov-2025 02-Sep-2026 22-Sep-2026

Table of Contents

Abstract

Currently, the Python interpreter allows any expression to be used as an annotation. But their most popular usage, type annotations, are restricted to only a small subset of possible expressions. This is because type annotations need to be introspectable at runtime and many valid expressions produce values where that is not possible. This greatly limits which expressions can be assigned a meaning within the typing spec and limits the expressiveness and ease of use of type annotations.

This PEP proposes to expand annotate functions such that any Python expression can be used in type annotations. It does this by creating a new annotation format which instructs annotate functions to return the annotation’s AST and namespaces. Runtime consumers of type annotations can then evaluate these into typing objects.

Motivation

Python type annotations are expressions attached to variable names that denote which values can be assigned to a variable. They are not only available to linters and type checkers via the source code directly but also to runtime reflection uses because the interpreter creates special annotate functions that compute the objects the annotation expressions evaluate to.

While this approach works well for many existing annotations and is easy to understand, it also greatly limits the kinds of expressions that can be used as type annotations. In the abstract, this is because the annotate functions evaluate such expressions using the same mechanism that Python uses for regular value expressions, and in many cases those semantics are not compatible with runtime reflection needs.

We will now provide several more concrete examples of expressions that could see use in type annotations but are currently not feasible. However, we are not directly advocating for any specific such usage and this PEP does not implement any of them. Rather, we are providing the groundwork that would make them and similar future proposals possible.

PEP 586 introduced literal types, which enumerate a concrete list of possible literal values, e.g. the numbers 1, 2 and 3. This is currently spelled as Literal[1, 2, 3]. Many users intuitively want to instead spell this as 1 | 2 | 3, writing out the bare literals and using the union operator to join them, which also is what other languages such as TypeScript use. This is currently not possible in Python because the expression 1 | 2 | 3 evaluates simply to 3 because | is interpreted as the bitwise-or operation rather than the union of types. Further, constant folding eliminates this expression from appearing anywhere in the generated bytecode, and it thus is impossible to retrieve the actual type annotation at runtime.

The above example only prevents us from using slightly shorter spellings for already existing types. There also are many type annotations that either are reasonably possible or even currently being discussed, but which are infeasible to implement currently. The currently open draft of PEP 827 introduces many such types. For example, it proposes conditional types, which are type expressions that evaluate to one of two types depending on some condition. Intuitively, these would be written as FirstType if TypeCondition else OtherType, just like ternary expressions. But that is not possible because no matter what TypeCondition evaluates to at runtime, one of the other type expressions will never be evaluated and thus be invisible to runtime introspection.

Similar issues arise when defining other types that are defined using other existing types. For example, one might want to write {K: NotRequired[T] for K, T in SomeTypedDict} to define a typed dict that has the same definition as an existing typed dict, but where every key is optional. This is currently not possible because type objects cannot be iterated over in this way. This has also already been discussed in e.g. this thread.

Rationale

Overview

We propose to make it possible to use arbitrary expressions in type expressions while maintaining full runtime introspection capabilities by introducing a new format for annotate functions. It will instruct them to return objects that contain both the annotations’ ASTs and their namespace. These can then be used to construct the actual type objects that runtime introspection users are interested in.

For example, consider the following class:

class MyClass:
    a: int
    b: list[str]

When calling MyClass.__annotate__(Format.VALUE) it will still return the usual annotation dict {"a": int, "b": list[str]}. But when called as MyClass.__annotate__(Format.AST) we receive this dictionary:

{
    "a": AnnotationAST(
        ast.Expression(ast.Name("int")),
        AnnotationNamespace(...),
    ),
    "b": AnnotationAST(
        ast.Expression(ast.Subscript(ast.Name("list"), ast.Name("str"))),
        AnnotationNamespace(...),
    ),
}

Each value in this dictionary contains the AST of the annotation expression as it was found in the source code and a namespace that can be used to evaluate the AST into the correct typing objects. Note that this AST is the direct result of parsing the annotation without applying any optimizations such as constant folding. The namespace references all relevant enclosing scopes and thus will always contain the correct bindings for any names used in the annotation.

This format and the AST objects are not intended to be used directly by consumers of annotations. Rather, they are intended as a primarily internal representation that allows a new set of utility functions to evaluate them into the usual typing objects. For example, we propose get_type_annotations to be added to the typing module, which will internally perform the above call and then return the familiar {"a": int, "b": list[str]} annotation dictionary. It will internally use a simple helper that implements the semantics the typing spec defines, similar to this snippet:

def eval_type_AST(node, namespace):
    match node:
        case ast.Name:
            return namespace[node.id]
        case ast.Attribute:
            value = eval_type_AST(node.value, namespace)
            return getattr(value, node.attr)
        case ast.BinOp(left, ast.BitOr(), right):
            return Union[eval_type_AST(left, namespace), eval_type_AST(right, namespace)]
    ...

The power of this approach is that it lets us implement new type expressions such as the ones mentioned above by simply extending this evaluation logic. And since it is not implemented in the compiled bytecode itself, it can even be backported to older Python versions via the typing_extensions module. For example, if we wanted to implement the 1 | 2 | 3 literal type expression, it would be enough to add a new case like this:

def eval_type_AST(node, namespace):
    match node:
        ...

        case ast.Constant(value) if isinstance(value, int):
            return Literal[value]
    ...

We also propose to add some additional utility functionality related to annotate functions and these AST objects. In particular, a new create_annotate_function in the annotationlib module to easily synthesize an annotate function. The core of this functionality is already implemented in the dataclasses module, and we foresee that many users will need this in order to create annotate functions that support this somewhat more complex format.

Performance Impact

We measured the performance of the reference implementation across three groups of metrics: the time it takes to import modules, the memory footprint of annotate functions, and the time it takes to evaluate them. The first two of these are the most important since they impact every Python user, while the last only affects users that evaluate type annotations at runtime.

Relative to the current CPython implementation, import times for modules both with and without type annotations remained unchanged. Memory consumption decreased slightly by up to 5%, depending on the testing setup. The size of generated .pyc files decreased by about 6%. Note that these numbers are comparing the size of the entire modules, not just the annotate functions themselves.

Unfortunately, the annotate functions’ evaluation time increased significantly, by about a factor of 10 in real-world scenarios. However, even code that directly uses annotations only spends a small fraction of its time in these function calls. For example, when using the dataclasses module, each call slowed down only by between 10% and 45%. The exact slowdown depends heavily on the structure of the annotations that are evaluated and on whether forward references are used. We suspect that most real-world code will be on the lower end of this range.

In total, this proposal improves performance for many users that do not evaluate annotations at runtime. The negative impact on users that do evaluate annotations is only moderate, and these users are likely to also benefit the most from the new expressiveness that is being enabled. The implementation also leaves room for future optimizations that can be tailored to observed real-world usage.

Usage Outside of Annotations

While this proposal enables the usage of future type expressions in annotations, there also are other places where users want to write type expressions. For example in cast(<some complex expression>, value). Since the interpreter cannot differentiate these cases from other function calls, it is impossible for it to infer that it should use a mechanism like we suggest.

This problem can be avoided using an intermediate type alias:

type _TargetType = <some complex expression>
cast(_TargetType, value)

This lets you use any new type expression within <some complex expression> since type aliases also are implemented using annotate functions.

While this solution only presents a workaround to this problem, a more comprehensive fix would require adding a new keyword to the Python language, which we do not think is necessary. This decision can be reconsidered in the future if using intermediate type aliases like this does present a significant problem in real-world code.

Specification

Throughout this PEP, when we talk about annotate functions/methods, we are referring to both the __annotate__ special methods found on some objects and the following methods found on typing objects:

When referring to their return values, we mean either the objects contained in the dictionary returned by __annotate__ methods or the single object returned by the other methods.

The AST Format

A new value called AST is added to the annotationlib.Format enum with value 5. Annotate functions do not have to support this format. If an annotate function is called with this format, it must return a dict with str keys and AnnotationAST objects as values (or a single AnnotationAST object for evaluate functions, like evaluate_value on TypeAliasType). These are instances of a proposed new class that hold the annotation’s AST as an ast.Expression object and a namespace that is used to evaluate them.

Compiler-generated annotate functions will always support this format. They will store the necessary AST data as string constants and then construct new AnnotationAST objects each time they are called. The contained AST objects will be identical to the objects created by parsing the annotation source code directly.

Helper Functions

A new function typing.get_type_annotations is added that functions similarly to the existing annotationlib.get_annotations, but instead calls the underlying annotate function with Format.AST and then constructs typing objects using the new typing.evaluate_type_ast helper.

The existing typing.get_type_hints function will be deprecated. It has slightly different semantics to both annotationlib.get_annotations and the proposed function, which make it impossible to instead modify it to support the new functionality. It also will be completely superfluous since users of type annotations will need to call get_type_annotations instead to properly resolve any type annotations that contain new typing features. Leaving this function as-is will only create confusion about which function should be used.

The AST format also provides a simpler and more reliable method to create annotations in the STRING and FORWARDREF formats. Currently, compiler-generated annotate functions do not support these directly; rather, the helper functions in annotationlib try to create a best-effort AST and then unparse it into the requested format. Under this PEP these helper functions will instead use the AST format and unparse the result. This creates more accurate results for these formats in many cases.

In order to simplify the creation of synthesized annotate functions, a new helper function annotationlib.create_annotate_function will be added to annotationlib. It accepts a mapping from variable names to annotation objects in one of the existing annotation formats. Using these, it then returns an annotate function that supports all annotation formats by converting the passed-in values appropriately.

Implementation

How each annotate function can implement the AST format is an implementation detail. It will differ greatly not only between different Python interpreters but also between, e.g., compiler-generated annotate functions and user-defined or synthesized ones. We will now explain several changes in the implementation of compiler-generated annotate functions that are necessary to support the new format with the performance characteristics described above.

Currently, when a class like this is defined:

class MyClass:
    a: int
    b: list[str]

The compiler generates a __annotate__ method that behaves like this:

def __annotate__(format):
    if format > Format.VALUE_WITH_FAKE_GLOBALS:
        raise NotImplementedError
    return {"a": int, "b": list[str]}

That is, the annotations are compiled into each annotate function’s bytecode. In the reference implementation, we instead store the annotations’ ASTs and some additional name and scoping data in constants and then construct the objects in the requested format on demand. Since this process is identical for all annotate functions, we no longer need a new function object for each annotate and instead construct a simple AnnotateObject object that holds the necessary data and implements the __call__ method to return the annotation in the requested format. A simplified Python version of these looks like this:

class AnnotateObject:
    def __init__(self, asts, globals, ...):
        self.asts = asts
        self.globals = globals
        ...

    def __call__(self, format):
        namespace = AnnotationNamespace(globals=self.globals, ...)
        asts = {
            name: AnnotationAST(_build_ast(ast), namespace)
            for name, ast in self.asts.items()
        }
        if format == Format.AST:
            return asts
        elif format == Format.VALUE:
            return {
                name: ast.evaluate()
                for name, ast in asts.items()
            }
        elif format == Format.STRING:
            return {
                name: ast.unparse()
                for name, ast in asts.items()
            }
        ...

These objects do not store the ASTs as ast.AST objects directly, but rather strings containing a serialization of them, saving a significant amount of memory. Many of the performance characteristics are highly dependent on this storage representation and the implementation of the _build_ast method. If, for example, it is decided in the future that a different tradeoff between use cases and their performance is more desirable, the implementation of these objects can be changed easily without affecting the external interface.

Backwards Compatibility

The new annotation format and helper functions only add new functionality and thus do not have backwards compatibility concerns. The deprecation of typing.get_type_hints means that existing code that uses this function will break once it is removed from the standard library. We recommend that users migrate to annotationlib.get_annotations or typing.get_type_annotations, depending on which semantics they want to use. While these functions have slightly different behavior in some cases, they are a drop-in replacement most of the time.

We further want to point out that future additions like the ones outlined in the Motivation do not create backwards compatibility problems, even if adopted gradually.

Consider, for example, a change to the typing spec that makes it so that var: 1 is interpreted as a literal type Literal[1]. Users that evaluate the annotation method directly or with one of the existing helper methods will still observe the result as the plain integer 1. The changed semantics are only considered when the user opts into them by calling the annotation function with Format.AST or using typing.get_type_annotations.

In the most common use case, annotations are not consumed by the user directly but by some library code that introspects user-defined objects. Thus, adoption of new annotation semantics could be stalled if library authors have to worry about existing user annotations being reinterpreted to different objects when the library is updated. But this also is not an issue. The typing spec already is covered by backwards compatibility concerns, which means that any currently valid type annotations will not be changed to mean something different. Potential future changes can only define new semantics for syntactic constructs that currently are not valid type annotations and thus do not occur in user code.

It also is important to note that the Python AST itself is unstable and may change from version to version. This means that the internal structure of the values returned by annotate functions called with the AST format are similarly unstable. Because of this, we encourage users to not use this format directly but to rely on the helper functions that return the usual typing objects. These functions and their return values are part of the typing spec and thus are stable; a change in the AST would only change what their implementation looks like.

Security Implications

There are no known security implications for this change. Calling annotation functions already could execute arbitrary code defined in the annotated object. Python also already offers the capability to access the source code and compiled bytecode of introspected objects. The AST objects returned in the new format thus do not contain any previously unavailable information.

How to Teach This

Users of annotations are not directly affected by this proposal. It intends to enable the simplification of the way type annotations are spelled; any future changes based on this PEP will need to be evaluated on their own merits. Documentation will inform users that any new semantics are only natively supported in annotations. Since this is by far the most common place for types to be spelled, we expect this to not be a big limitation. Other places where types can occur already require users to wrap forward references in string literals, so this is a known practice. Type checkers should warn users if they do not wrap a type form using syntax that would be evaluated incorrectly in a place where it is statically known that a type form is expected.

The new format and changes to helper functions will be documented as part of the language standard. Libraries that introspect type annotations will be able to easily support any new type syntax by calling the provided utility functions and should document this behavior so that their users are made aware of any potential future changes.

One potential issue is the Usage Outside of Annotations discussed in the beginning. Since most users of type annotations will also use type checkers and/or linters, we recommend that these tools implement checks for these errors and suggest the fix via an intermediate type alias.

Reference Implementation

This proposal is prototyped in a CPython fork.

Rejected Ideas

Creating Typing Objects Within Annotate Functions

An initial idea was to modify annotate functions such that they create the relevant typing objects themselves. This can be achieved in several ways, for example via a new optional argument to signal typing-specific semantics or a new format. These approaches were rejected because they force the type-specific semantics to be defined in the interpreter itself. This not only limits the semantics to e.g. not require namespace lookups but also locks the usage of typing features to the Python version that is being used, rather than allowing the currently possible backporting via typing_extensions.

Storing Annotation Source Code

Instead of storing binary data that defines the annotations’ ASTs, we could simply store the annotations source code directly. This also was presented as a possibility all the way back in PEP 649. The two approaches are largely equivalent since one can create the AST from the source code and vice versa. While the unparsed AST is not necessarily the exact string that occurred in the source code since the AST does not preserve the precise formatting of the code, it is semantically equivalent and, in particular, is not affected by compiler optimizations.

In a performance comparison, the two data representations also are largely equivalent concerning import times and memory usage. However, parsing the source code to then create typing objects is significantly slower than working with the AST data, by a factor of 3. Storing the source code also greatly limits future optimizations since the data representation is directly exposed as the API.

Not Returning AST Namespaces

In order to properly evaluate an AST into the correct typing objects, the evaluating function needs to have access to the namespace the annotation was defined in. Initially it seems like this namespace can be reconstructed from the annotate function’s object since they contain the globals and cellvars used. However, this is not sufficient since namespaces can use more complex lookup logic when global statements or name mangling are used.

Lossy AST Serialization

There are some attributes of AST nodes that are not strictly necessary to evaluate them. Most importantly, many nodes store the position in source code where they were found. These are used in error messages and debugging tools. Storing these attributes in the internal AST serialization results in about 3% larger .pyc files. Since we already achieved a significant reduction in bytecode size, we decided to keep all attributes in the AST serialization and maintain full compatibility with existing tracebacks and debugging tools.

Acknowledgements

Thanks to Michael J. Sullivan for a lot of helpful feedback on this PEP and the reference implementation. Particularly, the idea to replace annotate functions with AnnotateObject, which led to a significant performance improvement.