Skip to main content

checked

A lightweight Python package for constraint-enforced state objects. Declare your fields once with type annotations, and the machinery enforces your rules on every assignment — no boilerplate validation code scattered through your logic.

from typing import Annotated
from checked import Checked
from checked.constraints import Immutable, Range, OneOf, Transitions, Requires, Derived

class CharacterState(Checked):
    health:       Annotated[int, Range(0, 100)] = 100
    alignment:    Annotated[str, OneOf("good", "neutral", "evil")] = "neutral"
    took_the_key: Annotated[bool, Immutable] = None
    relationship: Annotated[str,
                      OneOf("neutral", "friendly", "hostile"),
                      Transitions(
                          neutral=["friendly", "hostile"],
                          friendly=["neutral"],
                          hostile=[]
                      )] = "neutral"
    is_injured:   Annotated[bool, Derived(lambda s: s.health < 50)]

For how the library works internally, see ARCHITECTURE.md; for the design rationale and rejected alternatives, see THEORY.md.

Installation

checked requires Python 3.12 or newer and has no runtime dependencies beyond the standard library.

The package is not yet published on PyPI; for now, install directly from the repository.

With pip:

pip install git+https://github.com/xof/checked

With uv:

uv add "git+https://github.com/xof/checked"

Once the package is published, the usual forms will work:

pip install checked
uv add checked

Hacking on checked itself. Clone the repo and let uv set up the environment:

git clone https://github.com/xof/checked
cd checked
uv sync --dev              # installs runtime + dev dependencies (pytest, ruff, mypy)
uv run pytest -v           # run the test suite
uv run ruff check .        # lint
uv run mypy src/checked    # type-check

Core concepts

Checked

Subclass Checked and declare fields with standard Python annotations. Plain annotations get type enforcement only; wrap them in Annotated[] to add constraints.

class MyState(Checked):
    # Type-enforced only
    name: str = ""
    move_count: int = 0

    # Type-enforced + constrained
    health: Annotated[int, Range(0, 100)] = 100

Assignments to undeclared fields are rejected. Assignments of the wrong type are rejected. Everything else is validated against declared constraints.

s = MyState()
s.health = 50      # fine
s.health = 999     # ConstraintError: outside range [0, 100]
s.mood = "happy"   # ConstraintError: field 'mood' is not declared in the schema
s.health = "full"  # ConstraintError: expected int, got str

Keyword arguments to the constructor go through the same enforcement:

s = MyState(name="hero", health=75)

Defaults

A default assigned in the class body is written into every new instance as-is. That's fine for immutable values (int, str, tuples), but if you use a mutable default (list, dict, set) every instance will share the same object — see "Things that might surprise you" below.

For mutable defaults, use Field(default_factory=callable). The factory runs once per instance:

from checked import Checked, Field

class Bag(Checked):
    items:    list = Field(default_factory=list)
    counts:   dict = Field(default_factory=dict)
    defaults: dict = Field(default_factory=lambda: {"verbose": True})

Field is purely a default-expansion mechanism; constraints still compose with it:

inventory: Annotated[list, MaxLen(3)] = Field(default_factory=list)

Constraint classes

Multiple constraints on one field are all checked on every assignment.

Range(min_val, max_val)

Value must fall within [min_val, max_val] inclusive. Works on any comparable type.

health:     Annotated[int,   Range(0, 100)]   = 100
price:      Annotated[float, Range(0.0, 9.99)] = 1.99

OneOf(*values)

Value must be one of the specified options. Pairs naturally with Transitions.

alignment: Annotated[str, OneOf("good", "neutral", "evil")] = "neutral"

Immutable

Once set to a non-None value, the field cannot be changed. Use None as the default to indicate "not yet set."

took_the_key: Annotated[bool, Immutable] = None

s = MyState()
s.took_the_key = True   # fine — first assignment
s.took_the_key = False  # ImmutabilityError
s.took_the_key = True   # ImmutabilityError — same value still rejected

Immutable fields are a good fit for any write-once fact: the player's chosen name, a decision that can't be undone, a flag that records a one-time event.

Transitions(**allowed)

Declares which state transitions are permitted. The keys are "from" values; the lists are permitted "to" values. An empty list declares a terminal state.

relationship: Annotated[str,
    OneOf("neutral", "friendly", "hostile"),
    Transitions(
        neutral=["friendly", "hostile"],
        friendly=["neutral"],
        hostile=[]             # terminal — no escape
    )] = "neutral"

Attempting a transition not listed raises TransitionError. Attempting to leave a terminal state raises TransitionError. The initial assignment (from the default) is always allowed.

Requires(callable)

The field may only be set to a truthy value if the callable returns True when passed the current state. Setting to a falsy value bypasses the check.

magic_sword: Annotated[bool, Requires(lambda s: s.visited_armory)] = False

Named functions work too, and are preferable when the condition is non-trivial:

def has_completed_tutorial(s):
    return s.tutorial_complete and s.move_count > 5

advanced_weapon: Annotated[bool, Requires(has_completed_tutorial)] = False

Derived(callable)

A read-only field computed from other state. The callable receives the state object and returns the current value. Derived fields cannot be assigned to directly.

is_injured:    Annotated[bool, Derived(lambda s: s.health < 50)]
carrying_load: Annotated[int,  Derived(lambda s: sum(item.weight for item in s.inventory))]

Derived fields are included in as_dict() output but excluded from the writable field set.

MaxLen(max_length)

The value's len() must not exceed max_length. Works on lists, strings, dicts, or any sized type.

inventory: Annotated[list, MaxLen(3)]
tags:      Annotated[list, MaxLen(10)]

StrLen(min_len, max_len)

String length must fall within [min_len, max_len]. Either bound may be omitted.

username: Annotated[str, StrLen(1, 20)]
bio:      Annotated[str, StrLen(0, 500)]

Regex(pattern)

String value must match the given regular expression (anchored at the start via re.match).

postal_code: Annotated[str, Regex(r'^[A-Z]{2}\d{4}$')]
hex_color:   Annotated[str, Regex(r'^#[0-9a-fA-F]{6}$')]

Combining constraints

Any number of constraints can be stacked on one field. All are checked on every assignment.

username: Annotated[str, StrLen(1, 20), Regex(r'^\w+$')]
level:    Annotated[int, Range(1, 99), Immutable]        # can only be set once, within range

Utility methods

as_dict()

Returns a plain dict snapshot of all field values, including derived fields.

s.as_dict()
# {'health': 75, 'alignment': 'neutral', 'is_injured': False, ...}

delta(other)

Returns a dict of fields that differ between two state objects. Values are (self_value, other_value) tuples.

a = MyState()
b = a.copy()
b.health = 30

a.delta(b)
# {'health': (100, 30)}

Useful for logging, debugging, and computing move objects in history-based systems.

copy()

Returns a deep copy of the state object. The copy is fully independent — mutations to one do not affect the other.

before = state.copy()
state.health -= 20
before.delta(state)   # {'health': (100, 80)}

Inheritance

Constraints are inherited through the normal MRO. Subclasses can add new fields and constraints without affecting the parent.

class BaseState(Checked):
    health: Annotated[int, Range(0, 100)] = 100

class CombatState(BaseState):
    stamina:  Annotated[int, Range(0, 50)] = 50
    in_combat: bool = False

Custom constraint classes

A constraint is any object with a validate(field, old_value, new_value, state) method that raises ConstraintError (or a subclass) on violation.

from checked.exceptions import ConstraintError

class Even:
    """Value must be an even integer."""
    def validate(self, field, old_value, new_value, state):
        if new_value is not None and new_value % 2 != 0:
            raise ConstraintError(field, f"{new_value} is not even")

class MultipleOf:
    def __init__(self, n):
        self.n = n
    def validate(self, field, old_value, new_value, state):
        if new_value is not None and new_value % self.n != 0:
            raise ConstraintError(field, f"{new_value} is not a multiple of {self.n}")

Zero-argument constraint classes (like Even above) can be used bare in annotations without the empty parens:

score: Annotated[int, Even, Range(0, 1000)]

Putting it together

A small adventure-turn example that exercises most of the library at once:

from typing import Annotated
from checked import Checked, ConstraintError, TransitionError, ImmutabilityError
from checked.constraints import (
    Range, OneOf, Transitions, Requires, Derived, Immutable, StrLen,
)

class Adventurer(Checked):
    name:           Annotated[str,  StrLen(1, 20), Immutable] = None
    health:         Annotated[int,  Range(0, 100)] = 100
    visited_armory: bool = False
    has_sword:      Annotated[bool, Requires(lambda s: s.visited_armory)] = False
    status:         Annotated[str,
                        OneOf("exploring", "in_combat", "defeated"),
                        Transitions(
                            exploring=["in_combat"],
                            in_combat=["exploring", "defeated"],
                            defeated=[],
                        )] = "exploring"
    is_injured:     Annotated[bool, Derived(lambda s: s.health < 50)]


hero = Adventurer(name="Valen")
hero.visited_armory = True
hero.has_sword = True          # Requires is now satisfied
hero.status = "in_combat"      # exploring -> in_combat, allowed
hero.health = 30

print(hero.is_injured)         # True  — recomputed each time from health
print(hero.as_dict())
# {'name': 'Valen', 'health': 30, 'visited_armory': True, 'has_sword': True,
#  'status': 'in_combat', 'is_injured': True}

# Illegal moves are rejected before they take effect:
hero.status = "exploring"      # in_combat -> exploring, allowed

try:
    hero.status = "defeated"   # exploring -> defeated is NOT in the table
except TransitionError as e:
    print(f"blocked: {e}")

try:
    hero.name = "Someone Else" # Immutable — set at construction, can't change
except ImmutabilityError as e:
    print(f"blocked: {e}")

The important thing to notice: none of the illegal assignments reach storage. After the exceptions are caught, hero is exactly as it was before each failed write. This is the core guarantee the library provides — if hero exists and passed construction, every field in it satisfies every constraint.

Exceptions

All exceptions live in checked.exceptions and are also importable directly from checked.

Exception Raised when
ConstraintError Base class. General constraint violation, type mismatch, undeclared field.
ImmutabilityError An Immutable field is assigned after its initial set.
TransitionError A Transitions constraint rejects the attempted change.
DerivedFieldError A Derived field is assigned to directly.
InitializationError A field is read before being initialized (no default declared).

All exceptions carry .field (the field name) and .message (the reason) as attributes, in addition to the standard string representation.

How it works

In short: a metaclass builds each subclass's schema once at class-definition time, and the base class enforces it on every attribute read and write through a per-instance backing dict — so fields are validated on assignment, not just at construction. You don't need any of this to use the library.

For the full picture, see ARCHITECTURE.md (the structure, invariants, and landmines) and THEORY.md (the design rationale and the alternatives that were rejected).

Things that might surprise you

Immutable with a non-None default locks the field forever.

class Item(Checked):
    level: Annotated[int, Immutable] = 1   # looks sensible, but...

Item().level = 2   # ImmutabilityError — field was locked at level=1

Immutable treats any non-None current value as "already set." For a field you intend to set once at runtime, default to None:

class Item(Checked):
    level: Annotated[int, Immutable] = None

item = Item()
item.level = 42    # first set — allowed
item.level = 43    # ImmutabilityError

Mutable class-body defaults are shared across instances.

This is the familiar Python default-argument pitfall showing up in a new place:

class Bag(Checked):
    items: list = []

a, b = Bag(), Bag()
a.items.append("sword")
print(b.items)      # ['sword'] — same list object!

Use Field(default_factory=...) to get a fresh value per instance:

from checked import Checked, Field

class Bag(Checked):
    items: list = Field(default_factory=list)

a, b = Bag(), Bag()
a.items.append("sword")
print(b.items)      # [] — independent list

The factory is any zero-argument callable, so non-trivial defaults work too:

class Config(Checked):
    settings: dict = Field(default_factory=lambda: {"verbose": True, "retries": 3})

bool values pass int type checks.

isinstance(True, int) is True in Python, so this is accepted silently:

class Counter(Checked):
    value: int = 0

Counter().value = True     # accepted, stored as True

Not unique to checked, but worth flagging because people forget.

Requires evaluates against the state before the pending write.

class S(Checked):
    visited_armory: bool = False
    magic_sword:    Annotated[bool, Requires(lambda s: s.visited_armory)] = False

s = S()
s.magic_sword = True       # ConstraintError — s.visited_armory is still False
s.visited_armory = True
s.magic_sword = True       # ok

This is what you usually want for prerequisites ("has the player already done X?"), but it means a Requires condition cannot reference the value being assigned — only the existing state.

Constructor kwargs are applied in the order you pass them.

Python 3.7+ preserves dict ordering, and checked honors it. When one field's Requires depends on another, order matters:

S(magic_sword=True, visited_armory=True)   # ConstraintError — Requires trips first
S(visited_armory=True, magic_sword=True)   # fine

Regex is anchored at the start, not the end.

Regex uses re.match, which anchors the pattern at the start of the string but not the end. Add $ if you want full-string matching:

Regex(r'^[A-Z]{2}\d{4}')    # also matches "AB1234EXTRA"
Regex(r'^[A-Z]{2}\d{4}$')   # matches exactly "AB1234"

Derived fields recompute on every read.

There is no caching. Reading hero.is_injured twice runs the lambda twice. For cheap lambdas this is fine; for anything expensive, pull the value into a local:

injured = hero.is_injured
if injured: show_injury_ui()
if injured: play_injury_sound()

as_dict() shares references with internal state.

The returned dict is shallow:

d = bag.as_dict()
d['items'].append('bomb')    # this also mutates bag.items — and no constraints run

If you need an independent snapshot, wrap with copy.deepcopy(s.as_dict()).

Design notes

The principles behind the design — the schema is the specification, constraints compose rather than inherit, Derived keeps a computed value in one place, and None means "unset" — and the tradeoffs they came from are written up in THEORY.md.

Metadata

Release files for checked 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for checked 1.0.0
File Size Uploaded
checked-1.0.0.tar.gz 37.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for checked 1.0.0
File Interpreter ABI Platform
checked-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 57.7 kB

Release files / checked-1.0.0.tar.gz

Download URL checked-1.0.0.tar.gz
Size 37.4 kB
Tags Source
SHA-256 checksum
How to use checksums
cb9c9e2647e3cdeb9b6d9134c22b27045cde9ffdb21296b2adc36fb9b0f101d1
BLAKE2b-256 checksum
How to use checksums
cdc40af7ef15649af0bb9dfc4e1300099d69539556adf0c5f38fd7508e69c40f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 21, 2026.

Transparency log

Release files / checked-1.0.0-py3-none-any.whl

Download URL checked-1.0.0-py3-none-any.whl
Size 20.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e50bded8c3538515396a411953efb699096b4b2bef828bef10d47515e4666c88
BLAKE2b-256 checksum
How to use checksums
c1662b8aa10bdc613a2cf27c895029964103cef97fd9b1552f989f4631926fa4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 21, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page