Skip to content

← Structural Typing and the Hard Parts step 16 of 24

Medium Framework

Annotated: metadata the type checker deliberately ignores

Annotated[int, Gt(0)] constrains nothing. It is an int to every type checker in existence. The metadata is inert unless some runtime library goes looking for it.

That is not a defect — it is the design. Annotated[T, ...] is by definition assignable exactly where T is, which is what lets FastAPI put Depends(...), pydantic put Field(...) and typer put Option(...) into annotations without any of them needing checker support. The mistake is believing the checker is enforcing them.

Verified, so you do not have to wonder: with Port = Annotated[int, "1-65535"], passing 70000 produces no error; passing "80" produces arg-type. The int is enforced; the range is not.

The mechanics that actually bite

  • get_type_hints(fn) strips Annotated metadata. You must pass include_extras=True or you will get back a bare int and wonder where your Bounds went. This problem asserts both results.
  • Metadata is read off __metadata__ on the annotation object.
  • Order matters for equality (Annotated[int, A, B] != Annotated[int, B, A]) and duplicates are not de-duplicated.
  • Nested Annotated flattens innermost-first — but not through a PEP 695 type alias. Use a plain assignment alias (Port = Annotated[...]) when you intend the metadata to be visible; a type Port = Annotated[...] statement hides it behind a TypeAliasType.

Your task

Implement a decorator that makes the metadata mean something at runtime:

def check_bounds[**P, R](fn: Callable[P, R]) -> Callable[P, R]

At decoration time, read get_type_hints(fn, include_extras=True) and inspect.signature(fn). At call time, signature.bind(*args, **kwargs), apply_defaults(), and for each bound argument check every Bounds object in that parameter’s __metadata__. Out of range raises

ValueError(f"{name}={value} outside [{lo}, {hi}]")

Also implement:

def metadata_visible(param: str) -> tuple[bool, bool]

returning (does plain get_type_hints keep __metadata__, does include_extras) for the named parameter of bind.

def solve(ports: list[int], backlogs: list[int]) -> dict[str, object]

zips the two lists, calls bind(port, backlog) in a try, and appends either the result or f"rejected:{exc}". Returns {"results", "plain_has_metadata", "extras_has_metadata"}.

Parameters are validated in signature order, so an out-of-range port is reported even when backlog is also bad.

Two PEPs not to teach as available

PEP 746 (type-checking Annotated metadata against the annotated type) and PEP 835 are both Draft. Today, if you want the metadata validated, a runtime library — or the decorator you just wrote — has to do it.

Loading visualization…