Using Annotated for Validation Metadata

TL;DR

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.

One alias, two enforcers PositiveInt equals Annotated of int with Gt(0). The alias flows to a static layer that enforces int and to a runtime validator that reads Gt(0) and checks the value is greater than zero. PositiveInt = Annotated[int, Gt(0)] Static layer enforces: value is int ignores Gt(0) Runtime validator reads Gt(0), checks > 0 raises on -5
Declare the constraint once; the checker enforces the type and the validator enforces the rule.

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.

Metadata keeps its declared order Percentage equals Annotated of int with Gt of minus one then Le of one hundred; the metadata tuple stores the base type at index zero and each marker in the order written. Percentage = Annotated[int, Gt(-1), Le(100)] int index 0 — base type Gt(-1) · index 1 Le(100) · index 2 get_args(Percentage) (int, Gt(-1), Le(100)) order preserved — a consumer sees Gt(-1) first
The base type sits at index 0; each marker keeps the position it was written in.
# 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.

One alias, many reuse sites A single PositiveInt definition feeds a function parameter, a return type, a dataclass field, and a TypedDict field, giving all of them one source of truth for the constraint. PositiveInt defined once function param return type dataclass field TypedDict field
Define the constraint once and reuse it everywhere; a change to the bound updates every site at once.
# 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.

How the runtime validator flows The validator resolves hints with include_extras, binds the call arguments, and for each argument iterates its markers; a recognised marker that fails raises, otherwise the call proceeds. get_type_hints include_extras=True bind arguments signature.bind(*a, **k) get_args(hint)[1:] iterate the markers marker known and satisfied? no → raise yes → call the function
Resolve, bind, then check each marker; an unmet known constraint raises, otherwise the call proceeds.
# 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.

Runtime vs static analysis The constraint 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.

Silent symptom, cause, and fix Three rows pair a symptom with its cause and fix: empty markers means include_extras was omitted; isinstance never matches means a bare class was stored; and a value violating the bound passing means only the runtime enforces the rule. symptom cause fix get_args gives just (int,) include_extras omitted pass include_extras=True isinstance never matches stored the class Gt, not Gt(0) instantiate: Gt(0) -4 passes the type check checker ignores Gt(0) route through the validator
Each silent failure traces to one cause and one fix; none of them raise a type error on their own.
  • Calling get_type_hints without include_extras=True. The default strips every marker, so get_args returns 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 an int) triggers [arg-type] / reportArgumentType.
  • Writing the marker as a bare class instead of an instance. Annotated[int, Gt] stores the class, not Gt(0), so isinstance(meta, Gt) is False and the bound is unreachable. Instantiate the marker: Gt(0).
  • Redefining constraints inline everywhere. Repeating Annotated[int, Gt(0)] across signatures defeats the point. Define PositiveInt once 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 container get_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 single Gt(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.

Back to Annotated Types and Metadata