Expand description
§typestate-pipeline
Compile-time-checked typestate scaffolding for Rust: a dual-mode pipeline carrier for cross-phase state machines, and a named-field accumulator derive for argument bags. The two macros compose — a factory can run inside a pipeline phase, with its setters landing directly on the user’s carrier.
Looking for a feature? Every macro option has a worked entry in the guide on docs.rs — source code paired with a sketch of what the macro emits, backed by the
tests/expansionssuite that locks the surface in.
use typestate_pipeline::{Pipeline, TypestateFactory, pipelined, transitions};
#[derive(TypestateFactory)]
struct Profile {
#[field(required)] name: String,
#[field(required)] email: String,
#[field(default = 18)] age: u32,
}
#[derive(Debug)]
struct AuthError(&'static str);
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.0) }
}
impl std::error::Error for AuthError {}
pipelined!(Author, ctx = (), error = AuthError);
struct Registered { profile: Profile }
struct Deployed { profile: Profile, account_id: u64 }
#[transitions]
impl<'a> Author<'a, Registered> {
#[transition(into = Deployed)]
pub async fn deploy(state: Registered) -> Result<Deployed, AuthError> {
Ok(Deployed { profile: state.profile, account_id: 42 })
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), AuthError> {
let profile = ProfileFactory::new()
.name("Alice".into())
.email("alice@example.com".into())
.with_age(30) // optional — overrides the default of 18
.finalize();
let deployed = Author(Pipeline::resolved(&(), Registered { profile }))
.deploy()
.await?;
let state = deployed.0.into_state();
println!("{} got account #{}", state.profile.name, state.account_id);
Ok(())
}This example lives at
examples/minimal.rs
— run it with cargo run --example minimal. For the full feature
surface — pipeline(carrier = …) composition, the <Bag>Ready
companion trait, async-fallible setters that lift the chain to
InFlight — see
examples/quickstart.rs.
For a multi-phase pipeline with an async-fetch breakpoint, see
examples/dataset_authoring.rs
(behind the dataset-authoring-example feature).
Try removing .email(...) from the chain — the compiler refuses
.finalize() because the bag’s flag tuple no longer matches the
finalize-callable shape. The same compile-time check guards phase
transitions: .deploy() on a carrier whose state isn’t Registered
simply doesn’t typecheck.
When this is overkill. A four-field one-shot builder doesn’t need the typestate machinery — a struct literal or a thin
impl Defaultis fine. Reach fortypestate-pipelinewhen (a) the order of operations across multiple steps needs to be enforced at compile time, (b) a single bag accumulates many required and optional arguments before being consumed, or (c) you have an async pipeline whose intermediate states should not be observable to callers.
§Where this fits
Rust has excellent compile-time builders.
bon and
typed-builder both lift
required-field enforcement into the type system through polished derive
APIs, and derive_builder covers the
runtime-checked end of the same space. For a single struct assembled
and consumed in one place, those crates are the right tool — they have
mature ecosystems, careful documentation, and they solve that problem
directly.
typestate-pipeline is built around an adjacent question: what
happens when the work a chain represents is itself a sequence of
phases — register, configure, deploy — each carrying its own bag of
required and optional arguments, and where the whole sequence should
read as one expression rather than a string of let-bindings broken at
every await boundary. Three design choices follow:
-
The carrier has two modes.
Resolvedholds the current state;InFlightholds a pending future. An async transition lifts a chain intoInFlight, and every subsequent step — sync, async, fallible, or any mix — folds into that future. The chain reads as one expression and awaits once at the end, rather than breaking at every async boundary. -
The builder and the pipeline share a carrier. A bag declared
#[factory(pipeline(carrier = MyAuthor))]emits its setters on the pipeline carrier in both modes, so a phase that accumulates many parameters stays inside the chain — no detour to assemble a separate builder expression and hand it back in. -
Each flag combination is a structurally distinct sister type. An implementation choice: required-field enforcement is solved identically in established builder crates through
Option<T>storage with phantom-flag generics, and the unwrap is statically guaranteed safe in either approach. Distinct sister types let the auto-generated<Bag>Readycompanion trait express “any finalize-callable bag” as a single trait bound, rather than forcing generic code to spell out the full flag tuple at every use site. -
Storage cells are
MaybeUninit<T>, notOption<T>. A coupled choice. With the flag carried at the type level, anOption’sis_somediscriminator would be redundant — the type already says whether the field is set.MaybeUninit<T>removes the discriminator and keeps the struct layout uniform across every flag combination, at the cost of a small set ofunsafeoperations gated by type-level invariants (see Safety for the full accounting). Theno_unsafeCargo feature swapsMaybeUninit<T>for<Flag as Storage<T>>::Out(Twhen set,()when unset) for a zero-unsafecodegen path that trades uniform layout for the sister-shape representation.
These are design choices, not feature deltas. If your work is one
struct built once, or a state machine without per-phase arguments, the
established crates above are the more direct fit —
typestate-pipeline exists for the specific case where the two
problems compose.
§Mental model
Two orthogonal axes. Each macro operates on one of them, and they compose freely.
§Factory/Builder axis — #[derive(TypestateFactory)]
Every non-internal field on a derived bag carries a flag generic that
is either No (unset) or Yes (set). The full transition graph,
covering every #[field(…)] mutability attribute:
| Operation | Flag transition | Notes |
|---|---|---|
Factory::new() / Default::default() | initial | every flag No |
.field(val) | No → Yes | required field, default naming |
.with_field(val) | No → Yes | optional or default field |
.field_default() | No → Yes | uses the declared default expression |
.drop_field() | Yes → No | requires removable; drops the value |
.override_field(val) | Yes → Yes | requires overridable; drops old, stores new |
.finalize() | consumes self | every required flag must be Yes |
A field with #[field(default)] or #[field(default = expr)] may
finalize whether its flag is Yes (the user’s value is used) or No
(the default expression is evaluated). Required fields without a default
have no such relaxation.
#[field(internal)] fields don’t appear in the flag-generic list at all
— they’re set positionally on new(…) and have an unconditional getter.
§Carrier axis — #[transitions], pipelined!, impl_pipelined!
The pipeline carrier is dual-mode: Resolved holds the current state
directly, InFlight holds a pending future that will yield the next
state. Each #[transition] body shape picks which arrow it takes; the
two arms emitted per transition (one per starting mode) end up in
different places:
| Body shape | Resolved arm returns | InFlight arm returns |
|---|---|---|
Sync infallible — fn returning T | Resolved | InFlight |
Sync fallible — fn returning Result<T, E> | Result<Resolved, E> (handle at call site) | InFlight (folds into pending future) |
Async deferred — async fn (default for async) | InFlight (lifts the chain) | InFlight |
Async breakpoint — async fn + breakpoint | async fn → Result<Resolved, E> | async fn → Result<Resolved, E> |
Crosscutting: any InFlight carrier .await?s into a Resolved of the
same state via the carrier’s IntoFuture impl.
§Chain folding
A chain that mixes every body shape — sync infallible, sync fallible,
and async deferred — folds into a single terminal .await?. The
example below is verbatim from
tests/transitions/core/tests/full_chain_with_resolved_breakpoint_in_middle.rs:
let deployed: Author<Deployed> = Author::from_registered(&client, "ds-a", 0xCAFE)
.tag_version(7) // async deferred -> lifts Resolved to InFlight
.with_parallelism(8) // sync infallible -> folds into pending
.validate_and_finalize() // sync fallible -> folds Result into pending
.deploy() // async deferred -> folds into pending
.await?; // -> Author<Deployed, Resolved>Adding #[transition(into = …, breakpoint)] to one of the steps
(an async breakpoint) forces the chain to .await? at that step,
landing back in Resolved for whatever follows.
§Macros
Two proc-macros and one declarative macro pair, each operating on one
of the axes from the Mental model. They are independent
— either is useful on its own — but compose: a factory can run inside
a pipeline phase, and the pipeline(carrier = …) arm even emits its
setters directly on the user’s carrier.
§#[derive(TypestateFactory)]
Generates <Name>Factory<F1, F2, …> with one flag generic per field.
Setters consume self and transition the relevant flag from No to
Yes. finalize() is callable only when every required flag is Yes.
The headline example above shows the baseline shape; each row below
points at a worked entry in the guide
(source + expansion sketch) on docs.rs:
| Option | What it adds |
|---|---|
| Minimal | baseline — every field required, no options |
required / optional | naming change — field(val) vs with_field(val) |
default / default = expr | optional with fallback; emits <field>_default() helper |
removable | emit drop_<field>(self) reverting the flag to No |
overridable | emit override_<field>(self, val) on Yes-flagged bags |
internal | positional on new(…), locked from then on |
setter = my_fn | run a transformer inside the setter |
setter = …, fallible | transformer returns Result<_, E>; setter does too |
setter = …, async_fn | async setter; combine with fallible for async fallible |
setter = …, input = T | setter input type differs from the storage type |
name = … / setter = ident | rename the bag and individual setters |
pipeline(carrier = …) | also emit Resolved + InFlight method pairs on the carrier |
finalize_async(via = …, into = …) | async finalize hook |
<Bag>Ready companion trait | exit-side bound: accept “any finalize-callable bag” generically |
<Bag>Empty companion alias | entry-side type: alias for the all-No flag-tuple shape (e.g. SettingsEmpty = Settings<No, No, No>) |
#[factory(no_unsafe)] | safe-mode codegen path (see Safety) |
The full attribute reference lives in the
#[derive(TypestateFactory)] rustdoc.
§#[transitions]
Decorates an impl block on a tuple-struct newtype around Pipeline.
Each method marked #[transition(into = NextState)] is expanded into a
Resolved + InFlight method pair from a single source body. The
destination type is read off the carrier’s Pipelined<'a> impl as a
GAT projection (<Self as Pipelined<'a>>::Resolved<NextState>), so
carriers with extra generics or unusual ordering keep working as long
as the trait impl is correct.
use typestate_pipeline::{pipelined, transitions};
pipelined!(Author, ctx = Client, error = AuthoringError);
#[transitions]
impl<'a> Author<'a, Registered> {
#[transition(into = Versioned)]
pub async fn tag_version(state: Registered, ctx: &Client, version: u32)
-> Result<Versioned, AuthoringError>
{
ctx.tag(state.name.clone(), version).await;
Ok(Versioned { name: state.name, version })
}
}
// chain folds into a single terminal `.await?`
let v = author.tag_version(7).deploy().await?;| Form | Body shape |
|---|---|
| Sync infallible | fn returning a non-Result |
| Sync fallible | fn returning Result<_, E> |
| Async deferred | async fn (default) — lifts the chain to InFlight |
| Async breakpoint | async fn + breakpoint — forces an .await? |
Generated transition code uses no unsafe. Full reference:
#[transitions] rustdoc.
§pipelined! / impl_pipelined!
Declarative shorthand for the conventional carrier shape
(<'a, S, M = Resolved> tuple-struct newtype around Pipeline):
// declares the carrier struct + Pipelined impl + IntoFuture forwarding
typestate_pipeline::pipelined!(pub Author, ctx = Client, error = AuthoringError);
// alternative: hand-write the struct (custom derives, extra generics, …)
// and emit only the trait impls
typestate_pipeline::impl_pipelined!(Author, ctx = Client, error = AuthoringError);Both also emit a chainable inspect(|carrier| …) combinator on Resolved
and InFlight. See the guide
for worked examples of both.
§Safety
#[transitions] and pipelined! / impl_pipelined! emit no unsafe
in either codegen mode. #[derive(TypestateFactory)] uses three
unsafe operations by default — and a per-derive opt-out (#[factory(no_unsafe)],
gated on the no_unsafe Cargo feature) swaps them for a fully-safe
codegen path; jump to The no_unsafe opt-out
if that’s the only thing you want to know.
The three default-mode unsafe operations, each gated by a type-level
invariant:
-
MaybeUninit::assume_init_refin getters. Each generated getter sits behind an impl bound that pins the field’s flag toYes. The flag is the type-level witness that the field was written by the corresponding setter. -
MaybeUninit::assume_init_readinDrop,override_<field>,drop_<field>, andfinalize. Each set field is read out into an owned stack temp before any user-definedT::dropruns. The temps then auto-drop, which gives Rust’s panic-cleanup semantics: a panickingT::dropon one field still lets the remaining temps drop on unwind. (A naive sequence of in-placeassume_init_dropcalls would short-circuit at the first panic and leak the rest, since the surroundingMaybeUninitslots have no auto-drop fallback.) -
ptr::read+ManuallyDropin setters /finalize. Setters andfinalizemove fields out byptr::read;selfis wrapped inManuallyDropfirst so the originalDropdoes not run on moved-fromMaybeUninitslots.
§Implementation invariants
The generated code rests on two groups of invariants — ordering, which
keeps the unsafe paths panic- and cancellation-safe, and structural,
which keeps the macro’s type-level guarantees and hygiene intact. Each
maps to a regression suite linked at the end.
Ordering invariants — panic and cancellation safety in the unsafe paths:
-
Setter ordering. The transformer runs before
selfis wrapped inManuallyDrop. A failing transformer (a?short-circuit) or a future dropped mid-await therefore leavesselflive, and its normalDropreleases every set field. Inverting the order would leak. -
finalizeordering. All field reads land in stack locals before anydefault = …expression is evaluated. A panic in a default thunk unwinds with the already-read fields as owned locals that auto-drop. Inlining reads alongside defaults would leak fields after a panicking default — theirMaybeUninitslots would still be sitting inside theManuallyDrop-wrappedthis. -
override_<field>/drop_<field>ordering. The OLD value is read into a stack temp and the new bag is constructed before the temp’s auto-drop runs. A panic in the old value’sT::droptherefore unwinds with the new bag already in scope; its panic-safeDropreclaims the other fields.
Structural and hygiene invariants — the macro’s type-level guarantees and codegen hygiene:
-
Hygienic internal bindings. All macro-emitted identifiers carry a
__tsh_prefix —__tsh_markersfor the phantom field;__tsh_this,__tsh_field_value,__tsh_old_field,__tsh_new_bag,__tsh_finalize_<field>, and__tsh_guard_<field>for local bindings. The prefix is unlikely to collide with a user-supplied field name or with an identifier reachable inside adefault = …expression. -
Explicit
Sendobligations on async pipeline arms. The async Resolved and InFlight arms carry an explicitwhere InputBag: Send + 'a, OutputBag: Send + 'aclause. A non-Senduser field surfaces the diagnostic at the impl block instead of insideBox::pin(async move { … }). -
Sealed
Pipelinefields.Pipeline::ctxandPipeline::innerare private; proc-macros destructure carriers through the publicPipeline::into_parts/Pipeline::ctxaccessors. A user’s carrier newtype therefore cannot bypass the typestate machinery by hand-substitutinginneror forging a_tag/_errmarker. -
Pinned bag layout. The generated bag struct is annotated
#[repr(Rust)].MaybeUninit<T>reads viaptr::readrely on default alignment; a future#[repr(packed)]would silently break that assumption. -
PhantomDatamarker tuple is always a tuple. The marker is emitted asPhantomData<( F1, F2, … )>with a trailing comma after every element. With one flag this is the singleton(F,)rather than the parenthesised type(F)(which collapses toF); with zero flags it is(). The parenthesised single-type form would silently change variance and auto-trait inheritance.
Each invariant above is locked in by a regression suite, mirrored on
docs.rs at
tests::safety:
| Suite | What it locks in |
|---|---|
factory_no_leak | failing fallible setter / overrider / dropped async setter still drops the other set fields |
factory_panic_safety | the three ordering invariants above survive a panicking T::drop |
factory_hygiene | user fields named like macro internals compile cleanly; default = … still resolves user-scope helpers |
factory_phantom_shape | zero-, one-, and many-flag bags round-trip; the singleton (F,) preserves variance and auto traits |
factory_no_unsafe | parallel coverage suite for the safe-mode codegen path |
§The no_unsafe opt-out
Enabling the no_unsafe Cargo feature allows individual derives to opt
into a safe codegen path with #[factory(no_unsafe)]:
[dependencies]
typestate-pipeline = { version = "0.1", features = ["no_unsafe"] }#[derive(TypestateFactory)]
#[factory(no_unsafe)]
struct User { /* fields */ }The safe-mode bag swaps MaybeUninit<T> for <Flag as Storage<T>>::Out
— T when the flag is Yes, () when it is No. Each (Yes, …)
/ (No, …) flag combination is a structurally distinct sister type, so
no manual Drop is needed: setters write T, removers replace with
(), and Rust’s auto-derived drop handles both shapes. finalize() for
optional-with-default fields uses the trait method Storage::finalize_or,
resolved at monomorphization rather than via a runtime if.
Without the feature, #[factory(no_unsafe)] is rejected at expansion
time so a downstream typo cannot silently cross codegen modes. The
attribute is opt-in per derive — turning the feature on does not
change the codegen of any existing derive.
§Workspace layout
typestate-pipeline # facade — depend on this
├── typestate-pipeline-core # runtime: Pipeline, Mode, Pipelined, flag traits
└── typestate-pipeline-macros # proc-macros (use through the facade)The proc-macros emit fully-qualified paths through
::typestate_pipeline::__private::*, so always depend on the facade
crate; depending on the macros crate alone produces unresolved paths.
§Further reading
| Where | What |
|---|---|
guide | every macro option, with source + expansion sketch |
tests | the integration-test suite rendered as browsable docs |
#[derive(TypestateFactory)] | full attribute reference |
#[transitions] | full attribute reference |
Pipeline | the runtime carrier |
§License
Licensed under either of
- Apache License, Version 2.0
- MIT License
at your option.
§Implementation note
Both proc-macros emit fully-qualified paths through this crate’s
[__private] module as ::typestate_pipeline::__private::*. The
extern crate self as typestate_pipeline; declaration below makes
that absolute path resolve from in-package uses (lib src, integration
tests, examples) as well as from downstream consumers. Renamed deps
(helpers = { package = "typestate-pipeline" }) are detected via
proc_macro_crate and routed through ::helpers::*.
Modules§
- guide
docsrs - One continuous narrative covering every macro the crate
offers — the factory first, then
#[transitions], then the carrier macros (pipelined!/impl_pipelined!), then the combinations. Each section pairs runnable source (the same files the test suite compiles) with a sketch of the generated surface, so you can read what the macros emit without runningcargo expand. - tests
docsrs - The integration test catalog — every test file rendered as a browsable page so you can see exactly what behavior is locked in.
Macros§
- impl_
pipelined - Implement
PipelinedandIntoFuturefor an existing carrier newtype, plus the chainableinspectcombinator on bothResolvedandInFlightmodes. - pipelined
- Declare a typestate carrier in one line: emits the newtype struct, its
where M: Mode<…>clause, thePipelinedimpl, and theIntoFutureforwarding forInFlightmode.
Structs§
- InFlight
- Mode marker: the pipeline holds a future resolving to the state data.
The pipeline implements
IntoFuturein this mode. - Pipeline
- Dual-mode pipeline carrier.
- Resolved
- Mode marker: the pipeline holds resolved state data directly.
Enums§
- No
- Flag marker: the corresponding field has not been set.
- Yes
- Flag marker: the corresponding field has been set.
Traits§
- Mode
- Storage-shape selector for
Pipeline. - Pipelined
- Marker trait for typestate carrier newtypes.
- Satisfiable
- Per-field type-level flag selector.
- Satisfied
- Sub-trait of
Satisfiableimplemented only byYes. - Storage
- Per-field type-level storage selector for the safe-mode codegen path
(the path opted into via
#[factory(no_unsafe)]).
Type Aliases§
Attribute Macros§
- transitions
- Generate Resolved + InFlight method pairs from a single source body.
Derive Macros§
- Typestate
Factory - Derive a sibling typestate factory for a struct.