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)
| File | Size | Uploaded | |
|---|---|---|---|
| checked-1.0.0.tar.gz | 37.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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