Every time you turn a structure into outputs — validate it, document it, compile it to SQL, lint
it — you hand-write the same machinery: an if isinstance(...) ladder, a visitor class, a registry
keyed by type. It is bespoke each time. It does not compose — you can't add a case from outside, or
run two passes in one traversal, without rewiring it. And the type checker can't see through the
dispatch, so a missing case is a runtime surprise, not a red squiggle.
A stateless coding agent makes it sharper: it re-derives that machinery from a few thousand lines of context, gets one case wrong, and the dispatch hides the gap.
capability is the handful of primitives that machinery reduces to: a fold over
self-describing values, typed end to end. The values carry their own behaviour; a fold runs
them; and they compose — a new value, a new pass, a new target are each an addition, never an edit
to the core.
uv add capability # zero dependencies, stdlib onlyA capability is a frozen value that carries its own contribution to a typed context. A fold
threads a context through a sequence of them, dispatching to each by an isinstance check against a
protocol — a value that doesn't speak a target's protocol is skipped. Three targets here: a
request-time validator, a database column, a block of help text.
from dataclasses import dataclass, replace
from typing import Protocol, runtime_checkable
from capability import Phase, by_protocol
@dataclass(frozen=True, slots=True)
class Rule: # a request-time validator
max_len: int | None = None
@runtime_checkable
class Validated(Protocol):
def validate(self, ctx: Rule) -> Rule: ...
@dataclass(frozen=True, slots=True)
class Ddl: # a database column
column: str = "TEXT"
@runtime_checkable
class Stored(Protocol):
def ddl(self, ctx: Ddl) -> Ddl: ...
@dataclass(frozen=True, slots=True)
class Doc: # help lines, accumulated
lines: tuple[str, ...] = ()
@runtime_checkable
class Documented(Protocol):
def doc(self, ctx: Doc) -> Doc: ...
# Each interpretation reads the context the previous fact left, and extends it.
@dataclass(frozen=True, slots=True)
class MaxLen:
n: int
def validate(self, ctx: Rule) -> Rule:
return replace(ctx, max_len=self.n)
def ddl(self, ctx: Ddl) -> Ddl:
return replace(ctx, column=f"VARCHAR({self.n})")
def doc(self, ctx: Doc) -> Doc:
return replace(ctx, lines=(*ctx.lines, f"at most {self.n} characters"))
@dataclass(frozen=True, slots=True)
class Unique:
def ddl(self, ctx: Ddl) -> Ddl:
return replace(ctx, column=f"{ctx.column} UNIQUE")
def doc(self, ctx: Doc) -> Doc:
return replace(ctx, lines=(*ctx.lines, "must be unique"))
validate = Phase(Rule, by_protocol(Validated, lambda c, ctx: c.validate(ctx)))
store = Phase(Ddl, by_protocol(Stored, lambda c, ctx: c.ddl(ctx)))
document = Phase(Doc, by_protocol(Documented, lambda c, ctx: c.doc(ctx)))
field = (MaxLen(255), Unique())
validate.run(field) # Rule(max_len=255) — Unique has no validate, skipped
store.run(field) # Ddl(column='VARCHAR(255) UNIQUE') — both facts fold in
document.run(field) # Doc(lines=('at most 255 characters', 'must be unique'))Add a fact (MinLen, Indexed): it implements the protocols it has, the rest skip it. Add a
target: a new Phase, no fact changes. Every piece is a small typed value, and nothing in the core
enumerates them.
It does not remove the work — MaxLen still spells out validate, ddl, doc by hand. It removes
the machinery: no visitor, no registry, no dispatch ladder — and the typed apply keeps each call
honest under pyright --strict.
Phases compose into a Compiler — a keyed set with set-like operators that folds every phase over
the facts in a single pass:
from capability import Compiler
schema = Compiler((validate, store, document)) # a composable set of phases
bundle = schema.run(field) # one traversal, every phase at once
bundle.get(store) # Ddl(column='VARCHAR(255) UNIQUE')
bundle.get(document) # Doc(lines=('at most 255 characters', 'must be unique'))
# compilers compose like sets: a + b (union) a - b (restrict) a & b (intersect) a | b (override)The operators form an idempotent semilattice (A + A == A), so composing compilers is predictable;
running one fuses every phase into a single traversal (banana-split). Composition is the point —
primitives into phases, phases into compilers, compilers into each other. Bundle.get is typed: it
hands back exactly the phase's own context type.
The core primitive, with the typing stripped, is a left fold:
def fold(items, initial, step):
ctx = initial
for item in items:
ctx = step(item, ctx)
return ctxfold names no target — the meaning is all in step, which you build with by_protocol /
by_table (you don't hand fold a bare lambda; a Step carries the policy and lets fold pick
the sync or async path). A new target is a new step, not an edit. The package is nine small
files, ~370 lines, zero dependencies.
The contexts, protocols, and targets are yours. The core ships fold and the dispatch
policies; what they compute to is your code.
Everything past fold + a Step is a layer you can ignore until you want it.
| Import | What it gives you |
|---|---|
fold, Step |
the primitive: a typed left fold + the dispatch-policy wrapper |
by_protocol |
open on the data axis — a new fact that implements the protocol is picked up |
by_table |
open on the interpreter axis — dispatch by exact type, a per-target handler table |
chain |
run several dispatch policies in one pass |
Phase |
a reified target — a context factory + its step; Phase.run(items) |
Compiler, Bundle |
compose phases (+ - & |) and run them in one pass; Bundle.get(phase) returns that phase's typed context |
fold_tree |
the catamorphism for recursive (tree) descriptions |
rfold, scan |
a right fold; a left scan that keeps every intermediate context |
traced_fold, explain |
record each step and render it — free, because the data is immutable |
from_annotated, from_sidecar |
read facts off Annotated[T, ...] metadata or a class sidecar |
afold |
the async overload — an async def apply makes fold return a coroutine |
capability.reflect.by_method |
opt-in name-driven dispatch |
capability is not a new idea — it's a known one, generalised and kept as small typed data:
functools.singledispatchregisters handlers externally, one function at a time, away from the data. Here the interpretations live on the value, and onefoldruns many at once.- Pydantic's
Annotatedmetadata is this move for a single target — validation.capabilityis the same, generalised to arbitrary phases with an algebra to compose them;from_annotatedreads exactly that metadata. - Object algebras / tagless-final are the typed-FP relatives. This is the data encoding, so
the program stays inspectable — you can print it, diff it,
explainit — which a closure encoding can't.
- zero dependencies, stdlib only;
src/ispyright --strictclean; 100% branch coverage; theCompileralgebra and the fold laws are property-tested;- no
getattr/hasattrin the dispatch code. To be exact about whatby_protocoldoes at runtime:isinstanceagainst a@runtime_checkableProtocol, which matches on method-name presence, not signatures — the typedlambda c, ctx: c.validate(ctx)is what buys the call-site check. Name-driven dispatch is opt-in, incapability.reflect.
A capability is Reynolds' (1972) defunctionalised closure — a decision turned from code into an
inspectable record. The hot path is a left fold; the catamorphism it instances (Meijer–Fokkinga–
Paterson 1991, after Malcolm 1990) is fold_tree / rfold, for recursive shapes. And it keeps
Wadler's Expression Problem open on both axes.
Small, typed, composable — the rest is yours.
Made with 🧬 by @prostomarkeloff