2 · Constraints
The business rules themselves — Email, Interval, Len, etc.
Why
A constraint is just a class with one method: validate(value) -> None. That is the entire protocol. Anything that satisfies it can be used as a constraint — no registry, no metaclass, no plugin system. The toolkit stays small and trivially extensible.
How
Stack constraints inside typing.Annotated. They run independently, in order, against the same value.
Constraints also compose with logical wrappers (AnyOf, AllOf, Not) and per-element wrappers (Each, DictOf). See Constraints → Composition.
Per-element rules go inside the element type. iron-monk recognizes annotated element types inside list, set, tuple, and dict and validates each item automatically:
items: list[Annotated[int, Interval(gt=0)]]
mixed: list[Annotated[int, Interval(gt=0)] | Annotated[str, Len(min_len=1)] | None]
pair: tuple[Annotated[int, Interval(gt=0)], Annotated[str, Len(min_len=1)]]
links: dict[Annotated[str, LowerCase], Annotated[str, URL]]
Reach for the explicit Each(...) / DictOf(...) wrappers only when the container is untyped or you need to layer extra rules. See Constraints → Composition.
Nullability
Constraints are required by default. Passing None to a constrained field raises a NotNull error.
from typing import Annotated
from monk import monk
from monk.constraints import Email, Each, Len, Nullable, NotNull
@monk
class Profile:
email: Annotated[str, Email] # (1)!
nickname: Annotated[str, Len(max_len=10)] | None = None # (2)!
phone: Annotated[str, NotNull(message="Phone is required!"), Len(10)] # (3)!
tags: Annotated[list[str | None], Each(Nullable, Len(max_len=5))] # (4)!
- Required.
NoneraisesNotNull. - Optional.
iron-monknatively intercepts| None— noNullableconstraint needed at the field level. - Custom error message / code for missing data.
- Inside
Each(...)you must opt in withNullablebecauseEachruns constraints functionally over items.
Global Nullability (type-checker integration)
To let a runtime type checker (e.g. beartype) own required-field enforcement, flip the default:
Cross-field rules
Constraints can reference sibling fields with Ref(...):
from typing import Annotated
from monk import monk
from monk.constraints import Interval, Ref
@monk
class Range:
min_val: int
max_val: Annotated[int, Interval(gt=Ref("min_val"))]
Ref is resolved at validation time. See Cross-Field Validation for the full mechanic and the __monk_validate__ hook.
Custom constraints
The protocol is one method. To define your own, use the @constraint decorator:
from monk import constraint
@constraint
class Even:
message: str | None = None
def validate(self, value):
if value % 2 != 0:
raise ValueError("Must be even.")
See Customization for message templating, code overrides, and refs in custom constraints.
Gotchas
- Constraints never coerce.
Annotated[int, Interval(gt=0)]does not parse strings. Pass the right type or expect aTypeError. - Order matters when constraints share a domain.
Len(min_len=3)beforeLowerCasewill fail length first, lower-case second. Stable, but worth knowing. Eachdoes not consume iterators. Passing a generator raises — convert to a list/tuple first, or usevalidate_stream(see Pillar 3).
Next: Pillar 3 — Validation