What does typing.Annotated[int, meta] mean to a type checker, and who reads the metadata?
answer
- One annotation, two audiences
- The checker sees only the first argument
- Extras are inert data for tools
- Unknown markers are simply ignored
- Prefer a marker class to a string
basics
~20 sAnnotated[int, meta] is still int to a type checker -- the extra arguments are opaque to it. They are metadata for runtime consumers such as validation, serialization or dependency-injection libraries, which read whatever markers they recognise.
solid answer
~50 s`typing.Annotated`, added in Python 3.9 by PEP 593, lets one annotation carry both a type and arbitrary extra objects: `Annotated[float, Unit('fps')]`. The first argument is the real type and is all a checker sees -- `Annotated[float, ...]` is assignable to and from `float`, and the checker must not draw conclusions from the metadata. Everything after it is a payload for **runtime** consumers: a validation library reading a constraint, a web framework reading a parameter source, an ORM reading a column spec. They ignore markers they do not recognise, which is why unrelated tools can annotate the same field. It needs at least two arguments, nested forms flatten their metadata into one tuple, and equality includes the metadata, so `Annotated[int, 'a']` is not equal to `int`. Prefer a small marker class over a bare string so consumers can match on type.
code
python · 12 linesfrom dataclasses import dataclass
from typing import Annotated, get_args
@dataclass(frozen=True)
class Unit:
symbol: str
Fps = Annotated[float, Unit("fps")]
base, *extras = get_args(Fps)
print(base, extras)go deeper
Remember the shape and the headline: the first argument is the real type, everything after it is extra information for tools, and the type checker only cares about the first argument.
Explain the contract precisely -- metadata is opaque to checkers, at least two arguments are required, nesting flattens, and equality includes the metadata -- and name the kinds of runtime consumer that read it.
Demonstrate design taste: marker classes over strings, annotated aliases defined once, and a clear statement that a field is only validated if some tool actually reads the marker. Be able to spot markers nothing consumes.
Own the coupling question. Metadata in annotations ties domain models to the tools that read them; decide when that is a worthwhile simplification and when a separate schema keeps layers independent and portable.
### The gap it fills Before PEP 593 an annotation could say only one thing: the type. Anything else -- a unit, a range constraint, where a web framework should read a parameter from, how a serializer should name a field -- had to live somewhere else: a decorator, a separate mapping, a naming convention. That split meant the information drifted away from the thing it described. `typing.Annotated[T, *metadata]`, added in **Python 3.9**, puts both in one place: ```python Fps = Annotated[float, Unit("fps")] ``` The first argument is the type. Everything after it is arbitrary metadata -- any objects at all. ### The contract with the type checker The rule is simple and it is what interviewers are checking: **`Annotated[T, ...]` means `T`.** A checker strips the metadata and type-checks against `T` alone. So a value of type `float` may be passed where `Annotated[float, Unit("fps")]` is expected, and vice versa; annotating a parameter adds no static strictness whatsoever. A checker is explicitly not allowed to interpret the extras -- if it did, two tools attaching different markers would fight over the meaning of the same annotation. Two details follow from the design: * `Annotated` requires **at least two arguments**. `Annotated[int]` raises `TypeError`, because an annotation with no metadata is just the type. * **Equality includes the metadata.** `Annotated[int, "a"] == Annotated[int, "a"]` is `True`, and `Annotated[int, "a"] == int` is `False`. Two annotations carrying different markers are different objects, even though they mean the same type to the checker. * **Nesting flattens.** `Annotated[Annotated[int, "a"], "b"]` collapses to a single form whose metadata is `("a", "b")` in order, so wrapping an already-annotated alias accumulates markers rather than burying them. ### Who actually reads the metadata Runtime consumers, and only those that opt in. The typical shape is a library that walks the annotations of a function or class, looks for markers it recognises, and configures itself from them: * a **validation or parsing** layer reading a bound, a regular expression, or a string format; * a **web framework** reading where a parameter comes from -- path, query, header, body -- so the signature alone drives request binding; * an **ORM or serializer** reading a column type, a database-side default, or an output alias; * **your own code**, which is the underrated case: a units marker, a redaction flag on fields that must never be logged, an "internal only" tag a schema generator honours. Because unknown markers are simply skipped, several unrelated tools can annotate the same field without coordinating, which was PEP 593's central goal. Retrieving the metadata is a deliberate, separate step: an ordinary annotation lookup gives you the underlying type, and a consumer must explicitly ask to keep the extras (that is what the `include_extras` flag on `typing.get_type_hints` is for). The mechanics of resolving annotations at runtime are their own subject; what matters here is that the extras are *there*, in the annotation, retrievable by anything that asks. ### Practical guidance **Use a marker class, not a bare string.** `Annotated[float, "fps"]` is readable, but a consumer can only match it by string comparison, and two libraries could both claim the string. A tiny frozen dataclass -- `Unit("fps")` -- is matchable with `isinstance`, carries structured fields, and cannot collide with someone else's marker. **Alias the annotated form.** `Fps = Annotated[float, Unit("fps")]` used in twelve signatures keeps the marker definition in one place; the alias itself is still transparent, so nothing about assignability changes. **Do not smuggle behaviour into it.** The metadata is inert data. Anything that must actually run -- coercion, validation, defaulting -- runs in the consumer, and a field is only as validated as the tool that reads it. If no tool reads a marker, it is a comment with extra syntax. **Keep it cheap.** The objects live for the lifetime of the annotation. Markers should be small, immutable, and ideally hashable, not carriers of large state.
- Does annotating a parameter with `Annotated[int, SomeMarker()]` make a type checker stricter?No. The checker strips the metadata and checks against `int`, so a plain `int` is accepted and an `Annotated[int, ...]` value is accepted anywhere an `int` is wanted. Strictness comes only from a runtime consumer that reads the marker and enforces something. That separation is deliberate: if checkers interpreted the extras, two libraries attaching different markers would disagree about what the annotation means.
- Why prefer a small marker class over a plain string in the metadata?A consumer matches markers by identity or type. A class instance is matchable with `isinstance`, can carry structured fields, and cannot be confused with an unrelated library's string. Bare strings force fragile equality checks and collide easily. A frozen dataclass is the usual shape: cheap, immutable, hashable, and self-describing when you print the annotation while debugging.
- What happens if you write `Annotated[Annotated[int, 'a'], 'b']`?It flattens into a single annotated form whose metadata is the tuple `('a', 'b')`, in that order. That is intentional -- it lets you build on an already-annotated alias and add a marker without nesting the type. Because equality includes metadata, the flattened form is equal to `Annotated[int, 'a', 'b']` and is still just `int` to a type checker.
saying these in an interview costs you the question
- Says the checker validates the metadata
- Thinks Annotated narrows or restricts the underlying type
- Believes any library automatically enforces the markers
- Assumes Annotated[int] with one argument is legal
- Confuses the metadata with a runtime default value
- Claims nested Annotated hides the inner metadata