Skip to content

Execution Models

iron-monk exposes three validation entry points. The basics are covered in Core Concepts → Validation; this page documents the flags, edge cases, and recursion patterns power users hit in production.


validate_dict — Strict Mode and Sanitization

By default, validate_dict is strict: any key in the payload that is not in the schema raises a ValidationError. Pass drop_extra_keys=True to silently strip them and return a sanitized dict.

from typing import TypedDict
from monk import validate_dict

class UserDict(TypedDict):
    name: str

payload = {"name": "John", "is_admin": True}

# validate_dict(payload, UserDict)            # raises — "is_admin" rejected
clean = validate_dict(payload, UserDict, drop_extra_keys=True)
# {"name": "John"}

Use drop_extra_keys=True at the trust boundary (HTTP handlers, queue consumers) to defang attacker-controlled payloads before they reach your domain layer.


validate_dict — Partial Validation (PATCH)

For PATCH-style endpoints where the client sends only the fields they want to change, pass partial=True. Missing keys are skipped; present keys are validated as usual.

from typing import Annotated
from monk import validate_dict
from monk.constraints import Email, Interval, Len

class UserBase:
    age: Annotated[int, Interval(ge=18)]

class UserUpdate(UserBase):
    username: Annotated[str, Len(min_len=5)]

validate_dict({"username": "admin"}, UserUpdate, partial=True)

Partial does not relax NotNull. partial=True only ignores omitted keys. A client that explicitly sends {"username": None} still fails the NotNull check — explicit nulls remain semantically distinct from missing fields.

Use ordinary class inheritance (UserUpdate(UserBase)) to keep PATCH schemas DRY against creation schemas.


validate_stream — Lazy Iteration

Collection constraints (Each, Contains, Unique) refuse to consume exhaustible iterators — they would silently destroy the data they were validating. To validate a stream lazily, use validate_stream (or validate_async_stream):

from collections.abc import Iterator
from monk import validate_stream
from monk.constraints import Email

def process(stream: Iterator[str]) -> None:
    for email in validate_stream(stream, Email):
        send(email)

process(x for x in ["test@domain.com", "bad"])
# yields "test@domain.com", then raises ValidationError on "bad"

The async sibling has the same shape:

from monk import validate_async_stream

async def process(stream):
    async for email in validate_async_stream(stream, Email):
        await send(email)

Each item is validated as it is pulled — no buffering, no peeking ahead. Failures interrupt iteration immediately.


Standalone Execution

Every constraint exposes .validate(value). Calling it directly is the fastest path for one-off checks at function boundaries or in tests:

from monk.constraints import Email, Interval

Email().validate("test@domain.com")          # passes silently
Interval(ge=18).validate(12)                 # raises ValueError
Email().validate(123)                        # raises TypeError

Standalone calls raise native ValueError / TypeError rather than ValidationError — there is no field context to aggregate over. If you need aggregation in tests, build a @monk class and call validate().


Nested Schemas

Use Nested(schema) to validate raw nested dicts against another TypedDict or @monk class without instantiating them.

from typing import TypedDict, Annotated
from monk import validate_dict
from monk.constraints import Email, Len, Nested

class AddressDict(TypedDict):
    city: Annotated[str, Len(min_len=2)]

class UserDict(TypedDict):
    email: Annotated[str, Email]
    address: Annotated[AddressDict, Nested(AddressDict)]
    history: list[Annotated[AddressDict, Nested(AddressDict)]]   # auto-synth handles per-element

Nested accepts partial=True to propagate PATCH semantics into nested dicts.


Recursive Schemas

TypedDict (lazy reference)

TypedDict cannot reference itself directly. Pass a lambda to Nested so resolution happens at validation time:

from typing import TypedDict, Annotated
from monk.constraints import Len, Each, Nested

class Comment(TypedDict):
    text: Annotated[str, Len(min_len=1)]
    replies: Annotated[list["Comment"], Each(Nested(lambda: Comment))]

@monk Dataclass (string forward reference)

@monk dataclasses handle recursion natively via standard string forward references. No Nested, no lambda:

from typing import Annotated
from monk import monk, validate
from monk.constraints import Interval

@monk
class Node:
    id: Annotated[int, Interval(ge=1)]
    children: list["Node"]

validate(Node(id=1, children=[Node(id=2, children=[])]))

The recursion is bounded by your data, not your annotation — there is no cycle detection. Trust the data shape, or add a depth check in __monk_validate__.