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
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:
evaluate_valueontyping.TypeAliasTypeevaluate_bound,evaluate_constraints, andevaluate_defaultontyping.TypeVarevaluate_defaultontyping.ParamSpecevaluate_defaultontyping.TypeVarTuple
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.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.