Using Annotated for Validation Metadata
Pack constraint objects into Annotated[int, Gt(0)] so a single alias carries both the static type
and the runtime rule. Define it once — PositiveInt = Annotated[int, Gt(0)] — reuse it everywhere,
and let a validation framework read the extras with get_type_hints(fn, include_extras=True). Static
checkers see only int, so they never enforce the constraint; that is the runtime consumer’s job.
The value of Annotated for validation is that one name expresses two things at once: the type a checker enforces and the rule a framework enforces. Instead of writing int in the signature and repeating “must be positive” in a docstring or a separate validator, you fold both into a reusable alias. This page, part of Advanced Typing Patterns & Generics, builds a small constraint vocabulary, a reusable PositiveInt, and a framework-style consumer that reads the metadata.
A small constraint vocabulary
Constraint metadata is just an object the checker ignores. In real code you would use annotated_types.Gt, Ge, Len, and friends — the shared vocabulary Pydantic v2 and msgspec both understand — but the mechanism is identical for hand-rolled markers.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from dataclasses import dataclass
from typing import Annotated
@dataclass(frozen=True)
class Gt:
bound: int
@dataclass(frozen=True)
class Le:
bound: int
PositiveInt = Annotated[int, Gt(0)]
Percentage = Annotated[int, Gt(-1), Le(100)]
Each alias names the type once and carries its rules alongside. Percentage stacks two markers; the extras keep their order, so a consumer iterating them sees Gt(-1) then Le(100).
The markers are declared frozen=True for concrete reasons: a frozen dataclass is immutable (a shared Gt(0) can’t be mutated by one consumer and corrupt another), hashable (so the alias can be a dict key or live in a set), and gets a value-based __eq__ and __repr__ for free (Gt(0) == Gt(0) is True, and errors print Gt(bound=0) legibly). Note that a marker is an instance, not a class — Annotated[int, Gt] stores the class object and no consumer will match it. In production you rarely hand-roll these: annotated_types ships the standard vocabulary — Gt, Ge, Lt, Le, MultipleOf, Len, MinLen, MaxLen, Interval, Predicate, and Timezone — and Pydantic v2, msgspec, and cattrs all interpret those same objects, mapping them onto JSON-Schema keywords like exclusiveMinimum and maxLength. Metadata is not limited to descriptors either: a Predicate(str.isascii) or a Pydantic AfterValidator(func) carries a callable the consumer runs, so the same slot expresses both declarative bounds and imperative checks.
# Using the ecosystem-standard vocabulary
from annotated_types import Gt, Le, Len, MultipleOf
from typing import Annotated
Port = Annotated[int, Gt(0), Le(65535)]
NonEmptyName = Annotated[str, Len(1, 64)]
EvenCount = Annotated[int, MultipleOf(2)]
For rules that no descriptor captures, annotated_types.Predicate wraps a boolean callable, and Pydantic offers BeforeValidator/AfterValidator for the same purpose. These run arbitrary Python, so they express things like “must be lowercase” or “must be a valid slug” that Gt/Le cannot. Because the marker is still just an object in the tuple, mixing declarative and imperative constraints on one alias is fine — Annotated[str, Len(1, 40), Predicate(str.islower)] carries both a length bound and a callable check, and a consumer applies each in __metadata__ order.
from annotated_types import Predicate, Len
from typing import Annotated
Slug = Annotated[str, Len(1, 40), Predicate(lambda s: s.replace("-", "").isalnum())]
The reason annotated_types is worth adopting over private markers is that it is a shared contract, not a library dependency in the usual sense — it ships only tiny frozen dataclasses and no runtime engine. Pydantic v2, msgspec, and cattrs each recognise those exact classes, so an alias built from them is portable across validation backends: switch a project from msgspec to Pydantic and the constraint aliases keep working unchanged. Some markers are composites — Interval(gt=0, le=100) is sugar for the Gt(0) plus Le(100) pair — and consumers are free to normalise them however they like, because the checker never inspects any of it. What a consumer must not assume is deduplication: writing Annotated[int, Gt(0), Gt(5)] leaves both markers in __metadata__, and it is the consumer’s job to decide whether the tighter bound wins or the combination is an error.
Reusing the alias in signatures
Because PositiveInt collapses to int for the checker, it drops into any signature and behaves exactly like int for assignability and inference. You get the documentation and runtime rule for free with no change to static behaviour.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated
def create_order(quantity: PositiveInt, discount: Percentage) -> None:
reveal_type(quantity) # Revealed type is "builtins.int"
reveal_type(discount) # Revealed type is "builtins.int"
...
create_order(3, 20) # ok
create_order("3", 20) # mypy: [arg-type]; pyright: reportArgumentType
create_order(-4, 500) # type-checks fine — constraints are runtime-only
That third call is the key subtlety: -4 and 500 violate the constraints but pass the type check. mypy and pyright never look at Gt/Le, so nothing stops the values until a runtime validator inspects them.
The same alias slots into class-based schemas without any change in behaviour, which is where the single-source-of-truth payoff compounds. Reusing PositiveInt in a TypedDict, a dataclass, and a Pydantic model means one edit to the bound propagates everywhere:
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from dataclasses import dataclass
from typing import Annotated, TypedDict
class OrderDict(TypedDict):
quantity: PositiveInt # int to the checker; Gt(0) to the validator
discount: Percentage
@dataclass
class Order:
quantity: PositiveInt = 1
discount: Percentage = 0
Because the alias is int statically, it composes cleanly with everything the checker already understands: it accepts a default (quantity: PositiveInt = 1), nests inside containers (list[PositiveInt], dict[str, Percentage]), combines with Optional/X | None, and can even be the argument of a generic parameterised with a TypeVar. Under from __future__ import annotations the aliases become strings, but that changes nothing for consumers that resolve hints through get_type_hints(..., include_extras=True) — the markers reappear intact. The one thing reuse never buys you is static enforcement of the constraint: no matter how many places PositiveInt appears, the bound is only ever checked where a value actually flows through the runtime validator.
The clearest demonstration is a real Pydantic model that reuses the same aliases as fields. Static checkers infer the fields as int, so IDE autocomplete and assignment behave normally, while construction runs the bounds:
# pip install pydantic>=2 annotated_types
from annotated_types import Gt, Le
from pydantic import BaseModel
from typing import Annotated
PositiveInt = Annotated[int, Gt(0)]
Percentage = Annotated[int, Gt(-1), Le(100)]
class Order(BaseModel):
quantity: PositiveInt
discount: Percentage = 0
Order(quantity=3, discount=20) # ok
Order(quantity=-1, discount=20) # ValidationError: quantity must be > 0
To the checker, Order(quantity=-1) is a perfectly valid int argument; only Pydantic’s constructor rejects it. That split is the entire reason to declare the constraint on the alias rather than to write it inline in a validator: the type and the rule travel together, one edit changes both, and every field, parameter, and return type that reuses the alias stays consistent automatically.
A framework-style consumer
Here is the runtime half — the code a validation library runs. It resolves the annotations with include_extras=True, splits each hint with get_args, and applies whichever markers it recognises.
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from typing import Annotated, get_type_hints, get_args
import inspect
def validate_call(func, /, *args, **kwargs):
hints = get_type_hints(func, include_extras=True)
bound = inspect.signature(func).bind(*args, **kwargs)
for name, value in bound.arguments.items():
for meta in get_args(hints.get(name, ()))[1:]: # skip the base type
if isinstance(meta, Gt) and not value > meta.bound:
raise ValueError(f"{name}={value!r} must be > {meta.bound}")
if isinstance(meta, Le) and not value <= meta.bound:
raise ValueError(f"{name}={value!r} must be <= {meta.bound}")
return func(*args, **kwargs)
validate_call(create_order, 3, 20) # passes
validate_call(create_order, -4, 20) # ValueError: quantity=-4 must be > 0
get_args(hints[name]) returns (int, Gt(0)) for quantity; slicing off index 0 leaves just the markers. This is essentially what Pydantic v2’s @validate_call and FastAPI’s parameter system do, only with a richer marker set and cached validators. FastAPI additionally reads injection markers like Depends and Query from the same extras. Because the extras are opaque to the checker, a TypeVar-based generic or a type alias can wrap them without disturbing inference.
A few refinements separate the toy above from a production validator. First, wrap it as a decorator so callers opt in once and normal call syntax still works — that is exactly the shape of @validate_call:
# Python 3.11+, checked with mypy 1.10 / pyright 1.1.370
from functools import wraps
def validated(func):
@wraps(func)
def wrapper(*args, **kwargs):
return validate_call(func, *args, **kwargs)
return wrapper
Second, the loop above only inspects top-level markers; a hint like list[Annotated[int, Gt(0)]] hides its constraint one level down. get_type_hints resolves the outer type to list[...], and get_args on that returns (Annotated[int, Gt(0)],) — the constraint is on the element type, not the parameter, so a flat scan misses it entirely. A real validator therefore walks the type tree: it checks whether a hint is a container via get_origin, recurses into each element type, and only applies markers once it reaches a leaf Annotated. Pydantic and msgspec do exactly this, which is why list[PositiveInt] validates every element rather than the list object. Third, calling bound.apply_defaults() before iterating ensures parameters left to their defaults are validated too, not skipped. Fourth, unknown markers must be ignored, not rejected — the isinstance filter already does this, and it is what lets Pydantic’s Field and FastAPI’s Depends ride on the same annotation as annotated_types.Gt. Production tools also decide between fail-fast (raise on the first bad value) and aggregation (collect every violation into one error, the way Pydantic reports a list of errors), and they cache the resolved (base, markers) per function so get_type_hints runs once rather than on every call. mypy and pyright, of course, see none of this: to them validate_call takes an arbitrary callable and the constraints never existed.
In practice you delegate all of this to a real library. The very same aliases built from annotated_types feed Pydantic v2 unchanged — TypeAdapter validates a lone alias, and @validate_call validates a whole signature — so hand-rolling the consumer is only worthwhile when you own the marker vocabulary:
# pip install pydantic>=2 annotated_types
from annotated_types import Gt, Le
from pydantic import TypeAdapter, validate_call
from typing import Annotated
Percentage = Annotated[int, Gt(-1), Le(100)]
TypeAdapter(Percentage).validate_python(150) # raises ValidationError: <= 100
@validate_call
def create_order(quantity: Annotated[int, Gt(0)], discount: Percentage) -> None: ...
create_order(-4, 20) # raises ValidationError: greater than 0
Pydantic reads the exact Gt(-1)/Le(100) instances your alias already carries, maps them to its internal constraints (and to JSON-Schema exclusiveMinimum/maximum when you export a schema), and raises a structured ValidationError listing every field that failed — the aggregation behaviour the toy validator only gestured at.
Gt(0) is enforced only inside validate_call at runtime — mypy and pyright treat quantity as plain int and happily accept -4. If you forget to route a value through the validator, the rule is never checked. The static layer guarantees the type; only the runtime guarantees the constraint.
Common mistakes
Nearly every validation-metadata bug is silent — no type error, no exception, just a constraint that quietly never runs. Reading each symptom back to its cause is the fastest way to spot which one you have hit.
- Calling
get_type_hintswithoutinclude_extras=True. The default strips every marker, soget_argsreturns just(int,)and your validator silently checks nothing. No error code fires — verify the extras are present. - Assuming the checker enforces the constraint.
create_order(-4, 500)type-checks; mypy never emits[arg-type]for a constraint violation. Only the type mismatch ("3"for anint) triggers[arg-type]/reportArgumentType. - Writing the marker as a bare class instead of an instance.
Annotated[int, Gt]stores the class, notGt(0), soisinstance(meta, Gt)isFalseand the bound is unreachable. Instantiate the marker:Gt(0). - Redefining constraints inline everywhere. Repeating
Annotated[int, Gt(0)]across signatures defeats the point. DefinePositiveIntonce and import it, so the rule has a single source of truth. - Forgetting that constraints on element types live one level down.
list[Annotated[int, Gt(0)]]constrains each element, not the list; a validator that only scans top-level markers never checks them. Recurse into containerget_args, or lean on Pydantic/msgspec, which already do. - Mutating a shared marker. If your marker is not frozen and two aliases share one instance, mutating it in one consumer corrupts the other. Keep markers immutable (
@dataclass(frozen=True)) so a singleGt(0)can be safely reused everywhere.
FAQ
Do I have to write my own constraint classes?
No. In production use annotated_types (Gt, Ge, Lt, Le, Len, MultipleOf), the shared vocabulary Pydantic v2 and msgspec both read. Hand-rolled markers are only useful when you also write the consumer.
Why does create_order(-4, 20) pass the type checker?
Because mypy and pyright reduce PositiveInt to int and never inspect the Gt(0) metadata. Constraint enforcement is a runtime concern; the checker only guarantees the argument is an int.