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.

# 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).

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.

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.

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

  • 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.

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