Shorthand syntax for Annotated (Type @ [metadata])

I propose a shorthand inspired by PEP 604:

int@[ann1, ann2] instead of Annotated[int, ann1, ann2]

The @ operator

It mirrors decorators, extending the mental model of attaching metadata to type expressions.

The list literal

It separates the base type from metadata, handles multiple annotations without ambiguity and is consistent with how we pass parameters to generics…

This is just a quick temperature check. I am happy to flesh things out if there is interest in a formal PEP.

EDIT (at the request of the OP):

The pep draft: PEP 9999 – Shorthand syntax for Annotated type metadata | peps.python.org
The live WASM demo: Shorthand syntax for Annotated type metadata

6 Likes

There was a recent discussion about this and related ideas: Dedicated syntax for `Annotated` - #34 by blhsing

4 Likes

Thanks for providing some context. My read of the previous discussion is that @ was a favored candidate. People liked the int @ Range(...) syntax, but consensus stalled over the ambiguity of chaining (e.g., int @ ann1 @ ann2).
A mandatory list literal (int@[ann1, ann2]) avoids those concerns.

2 Likes

I would definitely like a shorter syntax that doesn’t require an extra import.

It would be quite useful for users of FastAPI, Typer, SQLModel and other related libraries.

For NumPy users this’ll look very confusing, which uses @ for matrix multiplication between arrays and “array-likes” such as lists:

>>> import numpy as np
>>> a = np.array([[1, 2], [3, 4]])
>>> a @ [-1, 1]
array([1, 1])

So when I see int @ [ann1, ann2], my mind immediately goes to “matrix multiplication”.

list[int] looks like subscripting. Are there any cases where this would be really ambiguous? My initial instinct is that you wouldn’t use matrix multiplication where type annotations are valid or vice versa.

2 Likes

int @ ann1 @ ann2 feels to me like the obvious way to spell multiple annotations under this proposal. What’s wrong with that syntax?

4 Likes

In this case both operands are types. In case of Annotated, the RHS can be anything, including integers.
Plus, unlike other type operators, using @ for Annotated would be unique to Python. The Python syntax for subscripting (and union’ing) generic types is very common in other languages, and is even used in in type theory (e.g. in the “F” type system (wiki)).

1 Like

I really like the bracket-free syntax. The common case will likely be a single annotation (e.g., int @ Range(...)), and removing the brackets significantly reduces visual clutter.

It maintains a clear ‘inline decorator’ mental model and avoids the need to dive into complex type theory. This is a crucial distinction: Annotated was designed as a container for metadata (like Doc) that is often strictly outside the type system.

Technically: int @ Doc(...) @ Range(...) would flatten to Annotated[int, Doc(...), Range(...)]. This is consistent with current typing behavior and ensures we aren’t creating deep nested objects at runtime.

Joren: How do you feel about the bracket-free version? To me, it actually looks less like matrix multiplication than the list-based version did.

I’ll have a CPython prototype ready shortly, followed by a Mypy patch. I think seeing the low implementation complexity will help us move toward a formal PEP.

I’ve put together a C-native prototype for the bracket-free Type @ Metadata syntax on github.

It compiles and tests cleanly and is likely close to feature complete, though it isn’t ready to be landed or reviewed yet.

1 Like

Seems less confusing, but it still looks a bit awkward to me.
That said, I’m probably not the right person to ask, since I almost never use Annotated anyway (I spend most of my time in stubs).

1 Like

Following up on the CPython prototype, I’ve put together a Mypy PoC for the @ shorthand: github.com/till-varoquaux/mypy/tree/feature/at-type-annot.

1 Like

@Jelle , now that I have a working CPython prototype and a Mypy patch, I’d like to move forward with a PEP. The implementation handles all the edge cases I could identify; I believe it’s ready for a formal review (EDIT: It’s not quite there, I’d like to replace typing.Annotated with the new native c class instead of having both classes live side by side).

If you’re willing to sponsor, I can get a PEP number assigned and will have an .rst draft ready to share here ASAP.

I had this comment about a runtime issue in the previous thread that I guess I’d need to re-raise:

In short, presuming @ for Annotated works the same way as | for Union, if the type is a forward reference the entire annotation becomes an opaque forward reference under Format.FORWARDREF. This would potentially lose all of the metadata until the forward reference could be resolved (see the first example from that link).


Personally I prefer the explicit Annotated (both due to the runtime issues and for clarity), I would like it more if typing was a lighter import but I’d rather that be a driver for improving the import time of typing over adding more new syntax to avoid the import.

3 Likes

@DavidCEllis Interesting corner case. This is happening because the Stringifier in annotationlib doesn’t handle __or__ specially.
I’ve added handling for @ and this test case:

def test_matmul(self):

    class Example:
        fwd: undefined
        a: undefined @ "metadata"
        b: typing.Annotated[undefined, "metadata"]
        c: undefined @ "m1" @ "m2"

    annos = get_annotations(Example, format=Format.FORWARDREF)
    fwd = annos["fwd"]
    self.assertEqual(annos["a"], typing.Annotated[fwd, "metadata"])
    self.assertEqual(annos["b"], typing.Annotated[fwd, "metadata"])
    self.assertEqual(annos["c"], typing.Annotated[fwd, "m1", "m2"])

The implication being that str @ np.array([[2]]) @ np.array([[3]]) should perhaps resolve into Annotated[str, np.array([[4]])]?

That can’t quite be right, because operators should resolve left-to-right if I understand things correctly.

So then, the problems is (insane) scenarios like

class WeirdMeta(type):
    def __matmul__(cls, other):
        return type(other)

class Weird(metaclass=WeirdMeta):
    pass

assert Weird @ 2 == int

?

1 Like

It handles or in the way it does because doing otherwise requires making the additional assumption that the unknown object is a type. It wouldn’t be an unreasonable assumption, but it’s not required that the objects in annotations are types.

That wasn’t my implication, it’s that without making the additonal assumption that undefined here is a type and isn’t a numpy array itself.

undefined @ np.array([[2]]) @ np.array([[3]])
would resolve to
ForwardRef('undefined @ __annotationlib_name_1__ @ __annotationlib_name_2__', ...)

while Annotated[undefined, np.array([[2]]), np.array([[3]])]
is unambiguous and can resolve to
typing.Annotated[ForwardRef('undefined', ...), array([[2]]), array([[3]])]

Currently annotationlib doesn’t make this assumption for unions so doing so for Annotated is a change.

Yes, so I was trying to find a situation where the inference that

ForwardRef('undefined @ array([[2]])')

can be resolved into

typing.Annotated[ForwardRef('undefined'), array([[2]])]

is incorrect.

The scenario I came up with is

class WeirdMeta(type):
    def __matmul__(cls, other):
        return type(other)

class undefined(metaclass=WeirdMeta):
    pass

where the “correct” resolution is actually np.Array[1]


  1. I would argue that that is actually an incorrect way to resolve that type hint. Because if type checkers and run-time type users need to deal with such behaviour that’s a great cost, and it’s not going to gain us anything beyond the joy of the forbidden. But I know I’m going to lose that argument :wink: ↩︎

1 Like

Sure, if you’re requiring that undefined is a type. Otherwise a simpler solution would be that undefined is np.array([[1]]).

Type checkers would require the argument to be a type but the runtime doesn’t and as mentioned before, annotationlib doesn’t make that assumption for unions.

2 Likes

Exactly and I could see an argument against assuming annotations are types in annotationlib. IMHO this is a reasonable constraint for FORWARDREF, we should go ahead with @ and update the documentation accordingly.

2 Likes