Cross-Field Validation
Some rules cannot live on a single field — they only make sense relative to siblings. iron-monk exposes two complementary tools:
| Tool | Style | Use it when |
|---|---|---|
Ref(...) |
Declarative — lives inside the constraint that owns the error. | Field B's bounds, equality, or membership depend on Field A. The check is naturally directional. |
__monk_validate__ |
Programmatic — a method on the class. | The rule is symmetric, spans 3+ fields, or needs Python logic that does not fit a constraint. |
You can use either or both. They run in a defined order (Refs first, then __monk_validate__) and aggregate into the same ValidationError.
If the value you need is not on the model — current user, tenant config, clock, feature flag — reach for
Ctxinstead. It is the same machinery asRef, but the value is supplied at the call site rather than read from a sibling.
Ref — Declarative Cross-Field Rules
Ref("field_name") is a marker. When a constraint receives a Ref, iron-monk resolves it to the sibling field's value at validation time, then injects it into the constraint before evaluation.
The reference is compiled once at decoration time into a blueprint, so runtime cost is the same as a constant value. The original constraint instance is never mutated — a clone is built per call, preserving thread safety.
Equality and inversion
from typing import Annotated
from monk import monk
from monk.constraints import Eq, Not, Ref
@monk
class Registration:
password: str
confirm_password: Annotated[str, Eq(Ref("password"))]
@monk
class ChangePassword:
old_password: str
new_password: Annotated[str, Not(Eq(Ref("old_password")))]
Dynamic bounds
from typing import Annotated
from monk import monk
from monk.constraints import Interval, Len, Ref
@monk
class AuctionBid:
starting_price: float
offer: Annotated[float, Interval(gt=Ref("starting_price"))]
@monk
class CreateWorkspace:
plan_max_users: int
invited_users: Annotated[list[str], Len(max_len=Ref("plan_max_users"))]
Dynamic membership
Validate inputs against lists supplied elsewhere in the same payload — useful when the API echoes back a menu the client must choose from.
from typing import Annotated
from monk import monk
from monk.constraints import OneOf, Subset, Ref
@monk
class PizzaOrder:
available_toppings: list[str]
chosen_toppings: Annotated[list[str], Subset(Ref("available_toppings"))]
@monk
class Election:
nominees: list[str]
winner: Annotated[str, OneOf(Ref("nominees"))]
Refs inside containers
The blueprint compiler is recursive. Ref works inside nested containers, dictionary keys, and logical wrappers.
from typing import Any, Annotated
from monk import monk
from monk.constraints import ContainsKeys, Ref
@monk
class ConfigValidator:
required_fields: list[str]
config_data: Annotated[dict[str, Any], ContainsKeys(Ref("required_fields"))]
Ref-aware constraints
The following built-ins accept Ref for at least one constructor argument:
Eq, Interval (all bounds), Len (min_len, max_len), ExactLen, MultipleOf, StartsWith, EndsWith, Contains, OneOf, Subset, ContainsKeys, CSV, DictOf, AnyOf, AllOf, Not.
Custom constraints can opt in by accepting Ref | <real type> on the relevant argument; see Customization.
Conditional Cross-Field Rules — When and Switch
Sometimes a sibling does not just parameterize a constraint — it determines whether the constraint applies at all. When and Switch are declarative wrappers built on the same blueprint compiler as Ref, so they pay no extra runtime cost and inherit recursive resolution.
When — two-branch conditionals
When(field, test, then, else_=None) resolves field (a Ref to a sibling), runs test against the resolved value, and applies then to the current field's value if test passes. If test fails and else_ is provided, else_ is applied instead; otherwise the current field is unchecked.
from typing import Annotated
from monk import monk
from monk.constraints import Eq, Len, Ref, When
@monk
class Order:
payment_method: str
cc_number: Annotated[
str,
When(field=Ref("payment_method"), test=Eq("credit"), then=Len(min_len=16, max_len=16)),
]
When payment_method == "credit", the credit-card length rule applies; otherwise cc_number is unchecked. Add else_ to enforce a fallback shape:
@monk
class Profile:
kind: str
handle: Annotated[
str,
When(field=Ref("kind"), test=Eq("admin"), then=Len(min_len=8), else_=Len(min_len=3)),
]
test, then, and else_ accept any constraint — bare classes (auto-instantiated), composed constraints (AnyOf, AllOf, Not), or container constraints (Each, DictOf).
Switch — multi-branch dispatch
When more than two branches key off a discriminator, chained When becomes noisy. Switch(field, cases, default=None) is the typed sugar for that pattern — pass a mapping from discriminator values to constraints.
from typing import Annotated
from monk import monk
from monk.constraints import Email, Len, Match, Ref, Switch
@monk
class Notification:
channel: str # "email" | "sms" | "push"
target: Annotated[
str,
Switch(
field=Ref("channel"),
cases={
"email": Email,
"sms": Match(r"^\+\d+$"),
"push": Len(min_len=64),
},
),
]
If channel does not match any case, validation fails with "No case matches discriminator ...". Provide default= to supply a catch-all:
Switch is strict by design: an unknown discriminator without a default is an error. This makes discriminated unions structurally exhaustive, mirroring how match statements behave.
__monk_validate__ — Programmatic Hook
For rules that do not fit a single constraint (multi-field invariants, conditional logic, expensive computations), implement __monk_validate__ on the class.
The hook runs after all field-level constraints have passed — so you never have to defend against bad inputs on individual fields.
Yielding multiple errors
yield an iterator of errors. Each entry is a string, 2-tuple, or 3-tuple:
| Form | Meaning |
|---|---|
"message" |
Model-wide error (no specific field). |
("field", "message") |
Field-specific error using the default code. |
("field", "message", "CODE") |
Field-specific error with custom code. |
from collections.abc import Iterator
from monk import monk
from monk.types import MonkError
@monk
class Registration:
password: str
password_confirm: str
age: int
def __monk_validate__(self) -> Iterator[MonkError] | None:
if self.password == "admin" and self.age < 18:
yield "Young users cannot use the admin password."
if self.password != self.password_confirm:
yield "password_confirm", "Passwords do not match."
if self.password == "superuser" and self.age < 21:
yield "age", "Superusers must be over 21", "YoungSuperUser"
Returning a single error
If you have one rule that either passes or fails, return a single error instead of yielding:
from monk import monk
from monk.types import MonkError
@monk
class Login:
username: str
def __monk_validate__(self) -> MonkError | None:
if self.username == "admin":
return "Admin login is disabled."
When to prefer which
- Reach for
Reffirst. It puts the error on the right field and survives schema refactors better. - Reach for
__monk_validate__when the rule spans many fields, the logic is expensive, or you need conditional flow that does not compose as a constraint.