Draft PEP: More Expressive Type Expressions

This is a continuation of my previous post on this topic. I’ve decided to make this a new thread since the ideas have been refocused a lot since then and my goal now is to essentially make a new run down of the problem and my proposed solution since there was a lot of confusion before. This post won’t contain every detail of my ideas and implementation, I’ll be more comprehensive in the rewrite of my draft PEP, but I hope it’s understandable.

Motivation

Just to recap, the way that type annotations currently work is that when the compiler encounters a function definition like def func(a: int, b: some_module.MyClass): ... it not only produces the bytecode for func itself, but also for an annotate function that is then stored as func.__annotate__ and behaves essentially like this:

def __annotate__(format, /):
    if format > Format.VALUE_WITH_FAKE_GLOBALS:
        raise NotImplementedError
    return {
        "a": int,
        "b": some_module.MyClass,
    }

This function is then later called to e.g. create the __annotations__ dict or by tools like the dataclass decorator. What this implementation solves are issues like forward references (since the function can be called later when the references are resolved) or not being able to properly inspect annotations at runtime (since the returned dict will contain the exact objects the annotation expressions specify and things like name lookups are resolved properly through the usual machinery).

But this implementation also means that annotation expressions are evaluated using the exact same semantics that normal expressions occurring in other places are. This heavily limits what kinds of expressions we can sensibly use as type expressions. For example, one might reasonably expect that Python uses syntax similar to typescript when defining literal types, you just write down the values like this: error_level: 1 | 2 | 3. But that’s not possible since at runtime that annotation looks identical to writing error_level: 3 because | is treated as the “bitwise or” operation and everything is constant folded together. So we have to write error_level: Literal[1, 2, 3] to make sure each literal value is preserved.

This restriction has now come up in several discussions. This article discusses several common wishes users have for writing types in an easier way, most of which are (in part) blocked by this. There has been work on type checking array sizes, one of the conclusions of which was that the syntax that is currently possible is too cumbersome for them to see real-world use. A long-standing wish is to have inline typed dicts, which currently would be forced to use needlessly cumbersome syntax. And most recently, PEP 827 proposes a comprehensive type manipulation system, which (IIRC) originally proposed a set of new type forms to handle this issue, but then expanded to a system similar to my proposal since it was deemed too unreadable.

I want to be clear that I am not proposing to implement any one of these examples or that the only thing making them impossible or difficult is how annotations are evaluated. Rather, my goal is to implement the baseline feature that makes additions like these possible. I think that while any one of these proposals might not meet the bar to be implemented, the fact that we now have several ideas that are promising and cause a lot of discussion that all need some variant of this change is a strong argument for its adoption.

My Proposal

My idea is to change annotate functions to no longer contain the bytecode that produces the value that expressions are evaluated to, but rather some data objects that encode the original expression AST. The above example could then look something like this (when only considering the code path for the AST format):

def __annotate__(format, /):
    asts = {
        "a": _build_ast_object("...some data..."),
        "b": _build_ast_object("...some data..."),
    }
    namespace = {}
    try:
        namespace["int"] = int
    except NameError:
        pass
    try:
        namespace["some_module"] = some_module
    except NameError:
        pass
    return asts, namespace

The AST data itself is stored in some opaque binary blob and is transformed into the normal ast.AST objects at runtime. We also compute a namespace that contains every name referenced in the annotation expressions. These are later used to evaluate the actual type objects that the annotations are defining and the try/except handling prevents forward references from being an issue.

This evaluation is done by some new helper functions added to the typing module. These behave similar to annotationlib.get_annotations. But they don’t directly pass through the return value of the annotate functions and instead call them to get the AST objects and then evaluate these using the returned namespaces and typing-specific semantics.

This implementation preserves backwards-compatibility since calling annotate functions with any of the existing formats returns the exact same objects. The possible new semantics that are specific to typing uses are only produced when you use the new typing helpers.

One downside of this solution is that this machinery is limited to annotate functions, and it thus doesn’t allow you to use any newly implemented typing features in some places. In particular, things like cast(Some Fancy Type, ...) will still be evaluated using the usual semantics and thus produce errors or even incorrect values. However, we can easily work around this by using type aliases like this:

type _MyAlias = Some Fancy Type
...
cast(_MyAlias, ...)

It’s certainly not a perfect solution, but since annotate functions cover most places where users actually want to express types I don’t think it’s too much of a hassle to sometimes have to fall back to this. And since type checkers can detect places where issues might occurr and the vast majority of users that are writing type expressions also run type checkers, accidentally forgetting an intermediate type alias shouldn’t happen too often.

I’ve implemented this functionality and several alternatives in my CPython fork. Other variants are implemented in other branches, but a lot of the approaches that didn’t pan out well are lost somewhere in the git tree. I can try to collect a full set of variations if wanted.

Alternative Implementations

Another proposed approach is to not produce AST objects, but rather to just store the annotation objects as strings, similar to PEP 563. I’ve tried out various implementations using both ASTs and strings and did some (admittedly basic) performance measurements. I measured memory usage by importing a selection of the most popular packages on PyPi that use type annotations and observing total used memory. The vast majority of that obviously are not the annotate functions themselves, but the differences between implementations were consistent enough that we can use these measurements comparatively. I then also measured the total execution time of the annotate functions in these modules. These are my relative results for each of the best variants:

Current AST Strings
Memory 100% 99.4% 99.2%
Time 1x 7x 24x

While the memory numbers are great, the runtime measurements are much, much less exciting. There unfortunately just isn’t really a way (or at least I haven’t found it) to get around the fact that executing bytecode is pretty fast compared to analzying some complex structure. To put some further context to these numbers, the difference in RAM usage between the AST and strings based approaches is within the range of my codegolfing. On this front, all three approaches are more or less identical. But the runtime measurements had much larger differences than simple omptimizations. Neither approach is great, but storing the AST data directly has the huge advantage that you then don’t need to parse the strings at runtime.

Because of these results, I heavily favor the AST based approach. It does have some downsides such as implementation complexity, but it performs much better and is more expandable in the future. In particular, I’ve found that skipping the resource heavy ast.AST objects as they are currently implemented and/or constructing the type objects in C cuts down execution time by about half. While either of those optimizations have significant disadvantages, it does mean that we have a lot more room for future improvements in the internal processing. Storing the annotations as strings locks us into a much more rigid representation and pipeline.

I also don’t think that these numbers should discourage us from this proposal. Slowing execution down by a factor of 7 is bad, but (at least in my observations) code generally doesn’t spend that much time constructing annotation objects for that to be a significant performance loss. And should that estimation be incorrect, we can save a lot of that overhead using different implementations that follow the same external API.

Open Questions

There are various open questions and implementation choices that I’m not sure about:

As was recently mentioned, associating a unique namespace to every annotation rather than returning a single namespace from the annotate function makes merging annotations from different modules more straightforward. It also keeps the annotation protocol more consistent since annotate functions then always return a dict mapping parameter/attribute names to values (or a single such value) instead of the AST format returning a tuple of such a dict and a namespace. However, this also duplicates a lot of information.

How do we want to handle forward references? My current implementation just builds a dict of existing names, which means that forward references are simply missing from the namespace. If you want to later resolve that forward reference, you have to call the annotate function again. This could be changed by not using a namespace dict but a function closure, but that increases code size.

How exactly do we store the AST data? My currently favored approach stores a binary representation of a DFS traversal of the AST nodes in a str object. This lets us greatly benefit from string interning, but also means that we need to maintain a C code serializer and parser for this. The vast majority of that code can be automatically generated from the grammar, but it’s still a somewhat finnicky format.

There’s probably many more things that still need to be figured out and things I missed here. I’m happy to get any feedback and thoughts.

5 Likes

Thanks for putting a clear summary of the previous thread. I also experimented with AI a couple weeks ago, with a solution similar to your alternative implementation:

storing annotations as strings unconditionally like PEP 563, and new Format variants added: SOURCE to get the original strings and TYPE_EXPR which compiles SOURCE’s format to an AST, and recurses into it to build the final annotation [1].

In both approaches, I think it is important to think about runtime cost (i.e. pure import time, that would affect all users including ones not relying on runtime introspection). Could you please clarify:

I then also measured the total execution time of the annotate functions in these modules.

By execution time, do you mean pure import time, or executing the __annotate__() functions for runtime introspection? I’ve measured locally, but get different results (+7% on your approach, +5% on mine).

Reproduction

Measured on macOS arm64, debug builds.

1. Build the three interpreters

# CPython main
git fetch https://github.com/python/cpython main
git worktree add --detach ../cpy-main FETCH_HEAD

# ImogenBits' ast_inline fork (head 53d31653379)
git fetch https://github.com/ImogenBits/cpython ast_inline                                                                                                                                                  
git worktree add --detach ../cpy-astinline FETCH_HEAD
# The fork's last commit switched the AST data from bytes to str but left a
# stale assert behind; every debug build aborts on the first annotate call
# without this one-line fix (release builds compile the assert out).
sed -i.bak 's/assert(PyBytes_CheckExact(data));/assert(PyUnicode_CheckExact(data));/' \                                                                                                                     
    ../cpy-astinline/Python/intrinsics.c

# The source-string prototype                                                                                                                                                                               
git fetch https://github.com/Viicos/cpython vp/experiment-typeexpr
git worktree add --detach ../cpy-source FETCH_HEAD
                                                                                                                                                                                                              
for d in ../cpy-main ../cpy-astinline ../cpy-source; do
    (cd $d && ./configure --with-pydebug -q && make -s -j8)
done

2. Generate the synthetic module

200 functions with 6 annotations each, 200 classes with 4 class-level annotations (one a string forward reference) plus one annotated method.

import sys
N = 200
lines = ["from collections.abc import Iterable, Mapping", ""]
for i in range(N):
    lines.append(f"def func_{i}(a: int, b: str | None = None, *args: int, key_{i}: list[dict[str, int]] = [], **kw: Mapping[str, tuple[int, str]]) -> Iterable[bytes]:")
    lines.append("    return ()")
    lines.append("")
    lines.append(f"class Class_{i}:")
    lines.append(f"    x: int")
    lines.append(f"    y: str | bytes | None")
    lines.append(f"    z: 'Class_{i}'")
    lines.append(f"    w: Mapping[str, list[tuple[int, ...]]]")
    lines.append(f"    def method(self, a: int, b: 'Class_{i}') -> None: ...")
    lines.append("")
with open(sys.argv[1], "w") as f:
    f.write("\n".join(lines))
mkdir bench && python3 gen_synth.py bench/synth_mod.py

3. pyc size, import time, first __annotations__ access

bench_import.py <dir> <label>: compiles the module with the running interpreter (so each pyc reflects that branch’s codegen), then times 15 fresh subprocesses and reports the median.

import py_compile, subprocess, sys, os
target_dir = sys.argv[1]
src = os.path.join(target_dir, "synth_mod.py")
pyc = os.path.join(target_dir, "synth_mod_" + sys.argv[2] + ".pyc")
py_compile.compile(src, cfile=pyc, doraise=True)
print(f"pyc size    : {os.path.getsize(pyc)} bytes")
code = (
    "import time; t = time.perf_counter_ns(); "
    "import importlib.util; "
    "spec = importlib.util.spec_from_file_location('synth_mod', {p!r}); "
    "m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); "
    "print(time.perf_counter_ns() - t)"
).format(p=pyc)
times = sorted(int(subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout) for _ in range(15))
print(f"import time : {times[7] / 1e6:.2f} ms (median of 15, from pyc)")
# time to materialize __annotations__ for every annotated object (first VALUE call)
code2 = (
    "import importlib.util, time; "
    "spec = importlib.util.spec_from_file_location('synth_mod', {p!r}); "
    "m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); "
    "objs = [v for v in vars(m).values() if hasattr(v, '__annotate__') and v.__annotate__ is not None]; "
    "t = time.perf_counter_ns(); "
    "[o.__annotations__ for o in objs]; "
    "print(time.perf_counter_ns() - t, len(objs))"
).format(p=pyc)
r = sorted(subprocess.run([sys.executable, "-c", code2], capture_output=True, text=True, check=True).stdout.split() for _ in range(15))
print(f"VALUE for all: {int(r[7][0]) / 1e6:.2f} ms to materialize __annotations__ of {r[7][1]} objects")
../cpy-main/python.exe      bench_import.py bench main
../cpy-astinline/python.exe bench_import.py bench theirs
../cpy-source/python.exe    bench_import.py bench source

Observed:

main ast_inline source prototype
pyc size 265,745 B 315,988 B (+18.9%) 305,507 B (+15.0%)
import from pyc, median of 15 fresh processes 3.49 ms 3.73 ms (+7%) 3.68 ms (+5%)
first __annotations__ access, all 400 objects 1.03 ms 19.50 ms (19×) 1.02 ms (1.0×)

I also feel like the AST format shouldn’t really be usable. It’s an implementation detail of how we construct more expressive type expressions, and so in my case that’s why I’m exposing the TYPE_EXPR format, which takes care of the AST->final type expression process [2].


My branch also introduces a new syntax to build type expressions strings, using backticks. At runtime they compile to typing.TypeExpr objects, which are lazy and follow the same semantics as lazy annotations we know today. This introduces challenges in how to teach this (e.g. why do you need to use a type expression string in this place), but I don’t think it is worse than teaching users to define a PEP 965 type alias to work around the issue.

Here is a fully working example:

from annotationlib import Format

class GenericClass[T]:
    pass


Parameterized = GenericClass[`int if some_deferred_cond else str`]

type_expr = Parameterized.__args__[0]
type_expr
#> <TypeExpr `int if some_deferred_cond else str`>
type_expr.evaluate()
#> NameError: name 'some_deferred_cond' is not defined
some_deferred_cond = 'whatever'
type_expr.evaluate(format=Format.TYPE_EXPR)
#> typing.ConditionalType(if_true=int, if_false=str, condition='whatever')

This can be used in any place where type annotations aren’t deferred (cast(), generics, etc).


  1. With e.g. ast.IfExp() transformed to a typing.ConditionalType(). ↩︎

  2. My branch still exposes the AST format for testing purposes, but most likely it shouldn’t be. ↩︎

I think you misunderstood me slightly here, I didn’t mean a unique namespace per annotation. Just a reference to the appropriate context for the annotation function the annotation originated from. This ‘context’ object itself would need to have references to all of the relevant namespaces (globals, locals etc) in order to be able to evaluate forward references correctly.

Essentially having an format that’s something more like this:

def __annotate__(format, /):
    match format:
        case Format.DEFERRED:
            context = _capture_annotation_context()
            annotations = {
                "a": DeferredAnnotation(<data object>, context),
                "b": DeferredAnnotation(<data object>, context),
            }
            return annotations
        ...

This is roughly what reannotate tries to do on current Python except it is limited in that it can only currently retrieve annotations that Format.STRING would support. So error_level: 1 | 2 | 3 is still DeferredAnnotation('3').

from annotationlib import Format
from reannotate import get_deferred_annotations

class Example:
    a: list[undefined]
    b: int

annos = get_deferred_annotations(Example)

# Annotations from the same CPython generated __annotate__ share context
assert annos['a'].evaluation_context is annos['b'].evaluation_context

print("Annotation:", annos['a'])
print("Internal Object:", annos['a']._obj)
print("Context Object:", annos['a'].evaluation_context)

print("As ForwardRef:", annos['a'].evaluate(format=Format.FORWARDREF))

# Now we define 'undefined' and the annotation will evaluate, the same as `ForwardRef`.
undefined = str

print("As VALUE:", annos['a'].evaluate())
Annotation: DeferredAnnotation('list[undefined]')
Internal Object: Subscript(value=Name(id='list', ctx=Load()), slice=Name(id='undefined', ctx=Load()), ctx=Load())
Context Object: <reannotate.EvaluationContext object at 0x1056a87c0>
As ForwardRef: list[ForwardRef('undefined', is_class=True, owner=<class '__main__.Example'>)]
As VALUE: list[str]

Working like this would also make it possible to essentially backport the format to current Python as is, and then improve the correctness of the string support when the underlying generated __annotate__ functions are supported.


Out of curiosity, what’s the memory usage/performance like if you do both? If the __annotate__ functions look more like.

def __annotate__(format, /):
    match format:
        case Format.VALUE | Format.VALUE_WITH_FAKE_GLOBALS:
            return {
                "a": int,
                "b": some_module.MyClass,
            }
        case Format.AST/DEFERRED:
            return { ... }
        case _:
            raise NotImplementedError(format)
2 Likes

@ImogenBits I just wanted to highlight this comment again from the other thread

I would recommend adding this use case to that issue, given that performance of AST is a concern for you :slightly_smiling_face:

1 Like

I should have clarified, my runtime numbers are referring to the time it takes to go from some module/class/function to a dictionary that contains actually usable type objects. That time is different from both of your numbers. It’s not the module import time, but it also isn’t just the time to call __annotate__. What your test is measuring is the time it takes to construct the current VALUE format, which your code does in exactly the same way as the current implementation (with an addition if check that fails beforehand), so it makes sense that we’re getting the same numbers. My implementation uses an admittedly terrible backwards compatibility method for that at the moment, which in my measurements also has similarly awful numbers.

But the relevant question is how long it takes to get to the type objects we actually want, which in your case is done by annotationlib.call_annotate_function(annotate, format=Format.TYPE_EXPR) and in my by typing.get_type_annotations(annotate, format=Format.VALUE). I’m using this script (modified to use the correct function for each implementation) to do measure that. I’ve also added measurements for import time and pyc file sizes now. These are the results I’m getting there (relative to current):

Import Time RAM Usage .pyc Size Execution Time
AST 91% 98% 105% 732%
String 97% 98% 107% 2421%
Your Implementation 97% 100% 103% 3541%

Since I haven’t looked at import times or .pyc sizes in detail so far, I’m not entirely sure why these numbers are that way and why your measurements and mine somewhat disagree there. But it’s good to see that your implementation and my string based one lead to essentially the same numbers since they are more or less the same approach. From this cursory analysis I’d say that the AST based approach still is favorable compared to a string based one.

I sort of agree and disagree. Looking at the performance numbers, I think that it’s important that we give library users (or us in the future) some way of hooking into the type object construction process to speed it up. I think that the AST objects are a pretty good middle ground for that, they are a reasonably stable interface that also provides a pretty nice API. Exposing something like the internal binary representation of the ASTs would be a bad idea I think.

I’m intentionally avoiding adding new syntax since that has been a pretty big hurdle for PEPs in the past and I’m not super convinced that risk is worth just having a somewhat nicer way to write things.

I phrased that badly. Of course we wouldn’t actually construct a copy of the namespace, I was just trying to differentiate how the namespaces and ASTs are nested. My current approach is

(
  {"a": ast_data1, "b": ast_data2},
  namespace,
)

and your approach, as I understand it,

{
  "a": (ast_data1, namespace1),
  "b": (ast_data2, namespace2),
}

where namespace1 is namespace 2 might hold (and you might have some bespoke object rather than a simple tuple). I don’t have a huge preference between the two variants, I think it just comes down to what library authors find nicer to work with.

I implemented it that way initially, but discarded the idea mainly for implementation complexity reasons. While we do get much faster execution numbers when we call __annotate__(Format.VALUE) that way, that really isn’t something that people want to be doing anyways. If some typing specific syntax is introduced, such a call will necessarily either raise an error or produce completely incorrect values. The only people that actually want to take that code path are users of annotations that don’t use them for typing purposes. I’ve frankly not seen anyone that actually does that in the last few years. So we’re basically just lugging around a small but significant (about 3% of RAM usage in my tests iirc) amount of useless bytecode and have to maintain a more complciated codegen process for no real benefit.

Yes, that issue was one of the possible optimization steps that we can do in the future if constructing type annotations is too slow. Thanks for the reminder, I should make a comment in that thread too.

So in this case, I roughly get the same numbers as you (my implementation is slower because it builds the AST and then recurses into it to build the final type expression, while your approach already has the AST available at compilation time, so it only needs to recurse through the AST).

In both cases, I feel like we shouldn’t overlook the impact on pure import time. 5%/7% isn’t negligible, especially as it impacts every Python user, no matter if they care or don’t care about runtime introspection.

That’s a good point, so I think it makes sense too then.

I agree it doesn’t necessarily need to be included in this work, although reviewers might be worried about new type expression syntax not being usable in non deferred contexts (even though this PEP would not propose any specific syntax yet), which might confuse Python users. This example can be provided as a potential way of working around the limitation in the future.

Two question:

  1. PEP586 stated that typing.Tuple[1,2,3] is not possible because that crashes with python 3.7, but it seems it no longer does since 3.11, so is that rationale for rejecting bare literals even still applicable? The only thing that causes issues are unions, no?
  2. Are there any more concrete examples for this proposal rather than just unions of int / string literals?

Because if all we want to fix is verboseness of something like error_level: Literal[1, 2, 3], I think there are simpler alternatives:

Your motivation section mentions checking array sizes. I imagine you may wish to do something like

def concat[n: int, m: int](left: Vec[n], right: Vec[m]) -> Vec[n + m]: ...

with the goal that a type checker would be able to do the arithmetic when checking something like concat(vec3, vec5) ? By translating the + on the type-vars into a + on the upper bounds / solved values of the type-vars ? But wouldn’t this run into problems of ill-posedness, for instance if we have something like n | m it would no longer be clear if this is now the union of the type-vars or the OR-operator on the solved types.

You also mention inline typed dicts, but again there are no concrete examples in the motivation section. I think it would really help a lot if you could add one or two demos per use case on how they would benefit from your proposal.

Yes, namespace1 is namespace2 holds for CPython’s generated __annotate__ functions but wouldn’t hold for ones that aren’t generated directly, such as dataclasses.

An example using my own classbuilder, which uses reannotate deferred annotations internally:

from annotationlib import get_annotations
from pprint import pp
from ducktools.classbuilder.prefab import prefab, get_attributes
from reannotate import get_deferred_annotations

@prefab
class A:
    a: list[unknown_type]

@prefab
class B(A):
    b: list[unknown_type]

attribs = get_attributes(B)
print("Undefined:")
print("a.type:", attribs['a'].type)
print("b.type:", attribs['b'].type)
print()

A.unknown_type = str
B.unknown_type = int

print("Defined:")
print("a.type:", attribs['a'].type)
print("b.type:", attribs['b'].type)
print("Annotations:", get_annotations(B.__init__))
print()

print("Deferred:")
annos = get_deferred_annotations(B.__init__)
pp(annos)
print("'a' context:", annos['a'].evaluation_context)
print("'b' context:", annos['b'].evaluation_context)
print("'return' context:", annos['return'].evaluation_context)
Undefined:
a.type: list[ForwardRef('unknown_type', is_class=True, owner=<class '__main__.A'>)]
b.type: list[ForwardRef('unknown_type', is_class=True, owner=<class '__main__.B'>)]

Defined:
a.type: list[str]
b.type: list[int]
Annotations: {'a': list[str], 'b': list[int], 'return': None}

Deferred:
{'a': DeferredAnnotation('list[unknown_type]'),
 'b': DeferredAnnotation('list[unknown_type]'),
 'return': DeferredAnnotation('None')}

'a' context: <reannotate.EvaluationContext object at 0x108421720>
'b' context: <reannotate.EvaluationContext object at 0x108421960>
'return' context: None

One of the reasons for using a bespoke object for the deferred type is that it needs to be possible to construct the object in a usable way from arbitrary objects. In the case of the dataclass-like __init__ function, this is the 'return': None annotation which doesn’t have a context to evaluate in as it’s already evaluated.

Another reason for using a bespoke object is that the actual representation we use can be kept internal. If we find we can do something faster internally that would require changing the actual internal representation we can do that without breaking the public interface.


To some extent I prefer the idea from @Viicos that will mark objects that require transformation with specific syntax and wrap them in some kind of TypeExpr object. I’m not so sure about the additional formats needed to support it though. I’d almost prefer they were just treated like type aliases, you get them as a blob in an annotation and you can evaluate them from there.

My main use of annotations at runtime is for dataclass-like things and in that case we only actually care about a few very specific annotations[1] that don’t require any of these advanced forms. I don’t want to make that use case significantly slower in order to support something it’s not going to use. I do however, need to capture the information in case somebody else consuming the dataclass-like __init__.__annotate__ does need to evaluate them.


  1. Mostly just ClassVar, InitVar and KW_ONLY ↩︎

Concrete examples are somewhat hard to do for this since we’re talking about possible new features. But if you allow me some liberty to make up syntax and semantics on the spot (which thus probably has some other unrelated problems), I can try:

The error_level: {1, 2, 3} syntax you suggest also has problems, namely that if we introspect it at runtime we see a normal python set with e.g. the normal set repr. If there’s some bug users should probably see an error message that’s specific to the type-level meaning of this expression rather than just that there’s some set there.

The most convincing example from an implementation point of view are conditional types. Let’s say you make a serialization library that serializes strings as sequences of ints and everything else as bytes for some reason. Then you might want to want to write something like def serialize[T](value: T) -> list[int] if IsSubclass[T, str] else bytes. This admittedly isn’t a very motivating example for why you’d want a type like that. But if we take that for granted, it’s currently fundamentally impossible to use a ternary in a type expression since at runtime one half of it will be completely invisible to any introspecting code.

For typed dicts, we can imagine syntax like this def func(a: {"k": int, "o": str}): ... Which runs into problems since the type object now is just an ordinary python dict. Let’s say we write var: {...} | SomeOtherType (where … is filled in appropriately). Here, we want that to just produce a Union object. But since dict already implements that operator we just get a TypeError since the right hand side isn’t another dict it can merge with. Changing that dunder definition also isn’t great since it then supresses a lot of genuinely useful error messages.

I totally understand that it’s hard to see exactly why we need this feature on its face. But in the last thread I started out with several concrete and more complex motivating examples and the discussion ended up being largely focused on those specific use cases and reasons for or against them. I’d like to avoid that happening again in this thread. If you want more detail on why we some people like myself are convinced that something like this is necessary, please try to read through the rejected ideas section of PEP 827 and the discourse thread, I think those are the most thorough explanations.

1 Like

Some of the interactions with “But when evalutating this, it’s just an ordinary dict” (et al) could be solved by a __future__ + changing the semantics of annotations under that future, if the goal is specifically the typing use of annotations.

I’ve personally felt that typing being the same syntax as python has consistently hit some limitations, especially with pre-existing decisions like strings in annotations being “maybe it’s a string literal, maybe it’s a forward ref”.

I’m not sure simply having a new annotation format fixes some of the pre-existing issues, but I do see how it’s easier to pitch than changing the semantics would be and just trying to further improve the implementation of the status quo.

I personally want annotations to be as low-cost as possible for uses where the annotations are never introspected, and I’m perfectly fine with that coming with restrictions on introspectability at runtime.

Future statements are meant to allow use of features that are expected to become the default in some future Python version, so this would deviate from its initial purpose.

It’s a bit hard to know in advance if only making use of the Python syntax is going to be too limiting, but my instinct is that the proposal as is allows plenty of use cases (as mentioned in this thread, conditional types, typed dicts comprehensions, etc).

As a comparison, we can draw the parallel with TypeScript and JavaScript, assuming JavaScript had type annotations [1] and generics (which Python has at runtime), TS only has a handful of specific syntax that errors in JS:

  • some keywords like readonly, keyof, extends, infer
  • inline functions, e.g. (a: string) => void.
  • type operators such as expr as T, expr satisfies T.

For some of them, reusing Python syntax is fine (e.g. readonly and keyof can be expressed using a type qualifier in Python as in ReadOnly[...], same for type casting, we could think of a CastTo[T1, T2] form).

The biggest limitation I see would be inline functions, and adding a new syntax has already been rejected.


  1. That is, the field: type syntax. ↩︎

One of the advantages of a more general AST (or string) based approach is that we can then keep the flexibility of landing new typing features in typing_extensions ahead of new Python releases and even backport them to old Python versions (starting with the version that implements this). Decoupling the typing object construction logic from the annotate function internals isn’t just a workaround, it’s a desirable feature on its own right.

2 Likes