zero-cost typestate lib
rust crate type-state compile-time
Rust 100%

README.md

ductor #

crates.io

so, ductor is a zero-cost rust typestate library for modeling stateful "units" with capability-driven transitions that get checked at compile time.

the idea is you use proc macros to describe what transitions are valid and what capabilities they need, and then the compiler rejects any invalid stuff before it even runs. no runtime panics from being in the wrong state, no missing-capability bugs... that kind of thing

ductus (latin) -- to lead, to conduct, to guide. i thought it fit.

quick start #

add ductor to your cargo.toml:

[dependencies]
ductor = "0.1"

then you define a state machine, a capability, and a unit:

use ductor::*;

#[typestate(derive(Debug))]
pub enum Door {
  #[transition(Closed)]
  Open,
  #[transition(Open)]
  Closed,
}

#[cap(derive(Debug))]
pub struct HasKey;

#[unit(derive(Debug))]
pub struct Lock;

// try adding a transition that doesn't exist, watch it fail
let lock = Lock::new(Closed, HasKey)
  .transition(|Closed| Open)
  .transition(|Open| Closed);

ok but what's the point #

the whole idea is compile-time state machines. you describe your states and transitions once, and the compiler tracks what state everything is in. methods only show up when you're in the right state, transitions only work when you have the right capabilities. it's all types, zero cost.

typestate pattern #

each state is its own type. you can only move between them if the transition is declared. ductor generates all the boilerplate from a simple enum.

#[typestate]
pub enum Connection {
  #[transition(Connected, requires = Tls)]
  Disconnected,
  Connected { addr: SocketAddr },
}

this gives you:

  • structs Disconnected and Connected (with IsState impls)
  • a family marker Connection (implements StateFamily)
  • a Transition<Disconnected, Connected> impl with Requirements = Tls

try calling .transition(|Disconnected| ...) with a target like Listening and the compiler just says no. it's pretty satisfying honestly

capabilities #

capabilities are little marker types your unit carries around. transitions and #[spec] blocks can declare what they need, and the compiler checks everything.

#[cap(derive(Debug))]
pub struct AdminPrivilege;

// this only compiles if your caps include AdminPrivilege:
let area = SecureArea::new(Restricted, caps!(AdminPrivilege))
  .transition(|Restricted| Open);

you can also require multiple caps at once with a tuple:

#[transition(Open, requires = (ReadCap, WriteCap))]
ReadOnly,

and with #[cap(as = (A, B, C))] you can have parent caps that proxy for others. so caps!(Admin) can satisfy requirements for Read, Write, and Delete all at once. neat huh? 😃

tuple states #

a single unit can manage multiple independent state machines at the same time using tuple states:

#[unit(states = (NetworkState, AuthState))]
pub struct MyService;

you use states!(...) to create the initial tuple and transition_at() to transition just one of them:

let svc = MyService::new(states!(NetworkDown, Guest), caps)
  .up(network)                    // NetworkDown -> NetworkUp
  .authenticate("user");          // Guest -> Authenticated

spec blocks #

#[spec] blocks let you attach methods to specific state (and capability) combos. methods just don't exist when you're not in the right state, the compiler just hides them.

#[spec(for = Connected, with = Tls)]
impl Connection {
  pub fn send(&self, data: &[u8]) { /* ... */ }
}

// `send()` only exists on Connection<Connected, Caps<(Tls,)>>:
conn.send(b"hello"); // works

use _ or () as wildcards for "any state" or "any cap".

typestate erasure (unknown states) #

sometimes you need to put units with different states into a collection, pass them through a generic API, or hold onto one without knowing its exact variant. Of<F> wraps any state belonging to family F behind a Box<dyn Any>, erasing the concrete type while remembering the family.

use ductor::*;

// erase a single-state unit:
let erased: Lock<Of<Door>, Caps<(HasKey,)>> = lock.into_unknown();

// check / borrow / recover at runtime:
assert!(erased.is::<Closed>());
let _: Option<&Closed> = erased.as_some::<Closed>();
let back: Option<Lock<Closed, Caps<(HasKey,)>>> = erased.into_some();

methods on units with Of<F> state:

method what it does
is::<S>() checks whether the erased state is S
as_some::<S>() borrows as Option<&S>
into_some::<S>() consumes the unit, returns Option<Unit<S, Caps>>
trim_unknown::<S>() alias for into_some

erasing a single component in a tuple-state unit:

// States<(LoggedIn, Connected)> -> States<(Of<Auth>, Connected)>
let svc = svc.into_unknown_at::<LoggedIn, _, _>();
let erased: Service<States<(Of<Auth>, Connected)>, _> = svc;

// check at a position:
assert!(svc.is_at::<LoggedIn, _>());

// recover at a position:
let svc: Service<States<(LoggedIn, Connected)>, _> = svc.trim_unknown_at::<LoggedIn, _, _>().unwrap();

creating an Of<F> directly:

let erased: Of<Door> = Of::new(Closed);
assert!(erased.is::<Closed>());

erasing everything (any state, even tuples, into the unit's own private family):

let totally_erased: Lock<Of<LockFamily>, _> = lock.into_unknown_all();

macro reference #

#[typestate] #

defines a state machine from an enum. each variant becomes a struct.

argument description
derive(Trait, ...) forwards derives to all generated structs

variant attributes:

attribute description
#[transition(Target)] declares a valid transition to Target
#[transition(Target, requires = Type)] same but requires a capability

#[cap] #

marks a struct as a capability type. with as = (A, B, ...) it acts as a parent cap, having it satisfies requirement bounds for a, b, etc.

argument description
derive(Trait, ...) forwards derives
as = Type or as = (Type, ...) proxy targets this cap satisfies

#[unit] #

generates a typed container with new(), transition(), and transition_at().

argument description
derive(Trait, ...) forwards derives
states = Type constrains the state generic to a single family
states = (A, B, ...) constrains to multiple families

the macro adds state: State and caps: Caps fields + generics automatically. if your struct has extra fields annotate how they're initialized:

annotation behavior
#[unit(default)] Default::default() (this is the default if omitted)
#[unit(take)] added as a parameter to new()
#[unit(construct = expr)] computed via the given expression (can reference state, caps, and earlier fields)
#[unit(derive(Debug))]
pub struct Service {
  #[unit(default)]
  url: String,
  #[unit(take)]
  port: u16,
  #[unit(construct = format!("{url}:{port}"))]
  addr: String,
}

#[spec] #

attaches methods to a specific state/capability combo.

argument description
for = Type state type (or tuple) this applies to
for = (A, B, ...) tuple state spec; _ or () = wildcard
with = Type required capability
with = (A, B, ...) multiple required caps

#[transit] #

used inside #[spec] blocks to mark a method as a state transition. rewrites the return type to reflect the new state/caps.

argument description
to = Type target state (or tuple; _/() = wildcard)
with = Type target caps (optional)

examples #

run any with cargo run --example <name>.

example file what it shows
simple examples/simple.rs the bare minimum door lock thing
raw_example examples/raw_example.rs same as simple but without any macros, all traits impl'd by hand
base examples/base.rs multi-state network service with spec blocks, the works
state_data examples/state_data.rs states that carry actual data fields through a workflow
capabilities examples/capabilities.rs gating operations behind capabilities, compile-time checks
multi_unit examples/multi_unit.rs composing multiple units into a bigger state machine
branching examples/branching.rs one-to-many transitions from a single source state
proxy_cap examples/proxy_cap.rs parent capability delegation with #[cap(as = ...)]
unknown_state examples/unknown_state.rs type erasure with Of<>, .into_unknown(), .is_at(), .trim_unknown_at()
field_inits examples/field_inits.rs custom fields inside of units