Jhanmn.Statement 0.1.2

dotnet add package Jhanmn.Statement --version 0.1.2
                    
NuGet\Install-Package Jhanmn.Statement -Version 0.1.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Jhanmn.Statement" Version="0.1.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Jhanmn.Statement" Version="0.1.2" />
                    
Directory.Packages.props
<PackageReference Include="Jhanmn.Statement" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Jhanmn.Statement --version 0.1.2
                    
#r "nuget: Jhanmn.Statement, 0.1.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Jhanmn.Statement@0.1.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Jhanmn.Statement&version=0.1.2
                    
Install as a Cake Addin
#tool nuget:?package=Jhanmn.Statement&version=0.1.2
                    
Install as a Cake Tool

Statement

CI NuGet NuGet Pre-release License .NET Standard 2.0

A lightweight, type-driven state machine library for .NET.

In Statement, each state is its own class. Transitions are expressed by switching the machine's current state type, and entry/exit behavior lives either on the state itself (via IStatement) or on the machine through a fluent builder API. Optional base-type constraints let you guarantee at compile time that every state in a machine implements a common interface or inherits from a common base class.

Features

  • States as first-class types — no string identifiers, no enums.
  • Fluent StateMachineBuilder API with OnEntry / OnExit / CanTransitionTo / CannotTransitionTo rules.
  • Optional typed machines (StateMachineBuilder.For<TBase>()) for compile-time safety.
  • Register states by type (auto-instantiated) or by pre-built instance (for states with constructor arguments).
  • Built-in IStatement interface for states that prefer to own their own entry/exit logic.
  • Global transition callbacks via AddOnStateChangedCallback for cross-cutting concerns like logging.
  • Mandatory initial state — StartIn<TState>() must be configured before Build(), otherwise a MachineSetupException is thrown. This guarantees the machine is never observed in a null state.
  • Trigger-based transitions — declare On<TTrigger>().GoTo<TTarget>() per state and drive the machine via machine.Fire(trigger). Supports marker types, enums, strings, or any value. Guards (If), payload-receiving side-effects (Do), and internal transitions (Ignore) are first-class.
  • Typed transition payloads — Fire(trigger, payload) / SetCurrentState<T>(payload) deliver data to entry and exit callbacks via OnEntryWith<TPayload> / OnExitWith<TPayload>, and to global callbacks via TransitionInformation.Payload. Payload-aware guards (If<TPayload>(p => …)) decide per-edge whether a payload is acceptable.

Quick start

Install the project as a reference (NuGet package coming later) and define your states:

// States are just plain classes — implementing IStatement is optional.
public class Idle { }

// Implement IStatement only if the state wants to own its entry/exit logic.
public class Running : IStatement
{
    public void OnEntry() => Console.WriteLine("started");
    public void OnExit()  => Console.WriteLine("stopped");
}

Build a machine and drive it by type:

using Statement.Fluent.Api;

var machine = StateMachineBuilder.New()
    .AddState<Idle>()
    .AddState<Running>(s => s
        .CanTransitionTo<Idle>()        // allow-list: only these targets
        .CanTransitionTo<Faulted>())
    .AddState<Faulted>()
    .StartIn<Idle>()
    .Build();

machine.SetCurrentState<Running>();          // fires Running.OnEntry
var current = machine.GetCurrentState();     // returns the Running instance

Transition rules: allow-list and forbidden

By default, all transitions are allowed. Use CanTransitionTo<T>() to define an explicit allow-list — when called, only the specified targets are legal from that state:

var machine = StateMachineBuilder.New()
    .AddState<Idle>(s => s
        .CanTransitionTo<Running>()
        .CanTransitionTo<Shutdown>())
    .AddState<Running>()
    .AddState<Shutdown>()
    .StartIn<Idle>()
    .Build();

machine.SetCurrentState<Running>();    // OK
machine.SetCurrentState<Shutdown>();   // OK (after being in Idle)

Alternatively, forbid specific targets using CannotTransitionTo<T>():

var machine = StateMachineBuilder.New()
    .AddState<Running>(s => s
        .CannotTransitionTo<Idle>()
        .CannotTransitionTo<Faulted>())
    .AddState<Idle>()
    .AddState<Faulted>()
    .StartIn<Running>()
    .Build();

When both allow-list and forbidden rules exist on the same state, forbidden takes precedence. Attempts to switch into any blocked target are silently ignored by default. See the next section to change that.

Handling failed transitions

By default, transitions blocked by a CannotTransitionTo rule are silently ignored. Configure a different policy via OnTransitionFailure:

using Statement.Failures;

// Throw on blocked transitions
var machine = StateMachineBuilder.New()
    .OnTransitionFailure(TransitionFailurePolicy.Throw)
    .AddState<Running>(s => s.CannotTransitionTo<Idle>())
    .AddState<Idle>()
    .StartIn<Running>()
    .Build();

// Or run a custom callback
var machine2 = StateMachineBuilder.New()
    .OnTransitionFailure(TransitionFailurePolicy.Invoke(info =>
        Console.WriteLine($"blocked: {info.From?.Name} -> {info.To.Name}")))
    .AddState<Running>(s => s.CannotTransitionTo<Idle>())
    .AddState<Idle>()
    .StartIn<Running>()
    .Build();

Attempting to switch to a state that was never registered always throws InvalidOperationException, regardless of the configured policy.

Observing every transition

Register a global callback to be notified whenever the machine moves to a new state. The callback runs after the previous state's OnExit and the current-state commit, but before the new state's OnEntry. Exceptions thrown from callbacks are swallowed so they cannot crash the machine.

var machine = StateMachineBuilder.New()
    .AddOnStateChangedCallback(info =>
        Console.WriteLine($"{info.FromType?.Name} -> {info.ToType?.Name}"))
    .AddState<Idle>()
    .AddState<Running>()
    .StartIn<Idle>()
    .Build();

AddOnStateChangedCallback can be called multiple times to register more than one observer.

Typed machine with a shared base type

var machine = StateMachineBuilder.For<IMyState>()
    .AddState<Connecting>()
    .AddState<Connected>()
    .StartIn<Connecting>()
    .BuildTyped();   // StateMachine<IMyState>

IMyState state = machine.GetCurrentState<IMyState>();

Pre-built state instances

For states that need constructor arguments or dependencies:

var configured = new WithConfig("hello");

var machine = StateMachineBuilder.New()
    .AddState<WithConfig>(configured)
    .StartIn<WithConfig>()
    .Build();

Trigger-driven transitions

Instead of (or alongside) calling SetCurrentState<T>(), you can declare named triggers per state and Fire() them. The current state owns the routing, so the same trigger can mean different things in different states.

public sealed record Open;
public sealed record Close;
public sealed record Lock(string KeyId);

public class Closed { }
public class Opened { }
public class Locked { }

var machine = StateMachineBuilder.New()
    .AddState<Closed>(s => s
        .On<Open>().GoTo<Opened>()
        .On<Lock>().If(() => hasKey).Do(t => Audit(t.KeyId)).GoTo<Locked>())
    .AddState<Opened>(s => s
        .On<Close>().GoTo<Closed>())
    .AddState<Locked>(s => s
        .On<Open>().Ignore())          // valid here, but does nothing
    .StartIn<Closed>()
    .Build();

machine.Fire(new Open());              // Closed -> Opened
machine.Fire(new Close());             // Opened -> Closed
machine.Fire(new Lock("k1"));          // Closed -> Locked (guard checks `hasKey`)
machine.Fire(new Open());              // ignored — no callbacks fire

The TriggerBuilder fragment supports:

Call Effect
.GoTo<TTarget>() Transition to the target state.
.Ignore() Internal transition: consume the trigger without firing OnExit / OnEntry.
.If(Func<bool>) Guard the transition. Failed guards route through TriggerFailurePolicy.
.If<TPayload>(Func<TPayload, bool>) Payload-aware guard. Fails (as GuardFailed) if the payload from Fire(trigger, payload) isn't a TPayload or the predicate returns false.
.Do(Action<TTrigger>) Side-effect that runs after the guard passes, before OnExit. Receives the trigger value (payload).

Triggers can be any non-null object: marker-type instances (new Open()), enum values (On(DoorTrigger.Open)), strings (On("open")), or even a Type. Use whatever fits your domain.

When Fire(...) finds no handler on the current state or a guard returns false, the configured TriggerFailurePolicy decides what happens — Silent (default), Throw (raises TriggerFailedException), or Invoke(callback) to receive a TriggerFailureInfo:

var machine = StateMachineBuilder.New()
    .OnTriggerFailure(TriggerFailurePolicy.Throw)
    .AddState<Closed>(s => s.On<Open>().GoTo<Opened>())
    .AddState<Opened>()
    .StartIn<Closed>()
    .Build();

machine.Fire(new Close());   // throws TriggerFailedException (NoHandler)

Transition payload

Both Fire(...) and SetCurrentState<T>() accept an optional object? payload. The target state can read it through a typed OnEntryWith<TPayload> callback. The payload also lands on TransitionInformation.Payload for global observers.

public sealed record FileData(string Path);

public class Idle { }
public class Loaded
{
    public string? Path { get; private set; }
    public void Load(FileData data) => Path = data.Path;
}

public sealed record Open;

var machine = StateMachineBuilder.New()
    .AddState<Idle>(s => s.On<Open>().GoTo<Loaded>())
    .AddState<Loaded>(s => s.OnEntryWith<FileData>((state, data) => state.Load(data)))
    .StartIn<Idle>()
    .Build();

machine.Fire(new Open(), new FileData("readme.md"));     // Loaded.Path == "readme.md"
machine.SetCurrentState<Loaded>(new FileData("a.txt"));  // works the same way

OnEntryWith<TPayload> and OnExitWith<TPayload> only fire when the supplied payload is assignable to TPayload. If the payload is missing or the wrong type, the callback is silently skipped — the transition itself still proceeds. Use the parameterless OnEntry(...) / OnExit(...) for behavior that must always run regardless of payload.

Both callbacks come in two flavors:

// OnEntryWith
.OnEntryWith<FileData>((state, data) => state.Load(data))    // state instance + payload
.OnEntryWith<FileData>(data => Console.WriteLine(data.Path)) // payload only

// OnExitWith
.OnExitWith<FileData>((state, data) => state.Cleanup(data))  // state instance + payload
.OnExitWith<FileData>(data => Console.WriteLine(data.Path))  // payload only

The full execution order for a transition is:

  1. OnExitWith<TPayload> / OnExit on the leaving state
  2. State commit (current state pointer is updated)
  3. Global AddOnStateChangedCallback observers
  4. OnEntryWith<TPayload> / OnEntry on the entering state

To reject a transition when the payload doesn't fit, use If<TPayload> on the trigger — it routes wrong-type or failing-predicate payloads through TriggerFailurePolicy as GuardFailed. When the guard fails, neither OnExitWith nor OnEntryWith fires:

.AddState<Idle>(s => s
    .On<Open>()
    .If<FileData>(p => p.Path.EndsWith(".txt"))   // only proceed for .txt files
    .GoTo<Loaded>())

Do(Action<TTrigger>) and OnEntryWith<TPayload> / OnExitWith<TPayload> are complementary, not redundant: Do reads the trigger value (and runs before OnExit), while the With callbacks read the payload as part of entry/exit.

Initial state is required

Every machine must declare its initial state at build time via StartIn<TState>(). Calling Build() or BuildTyped() without it throws MachineSetupException:

using Statement.Failures;

try
{
    var machine = StateMachineBuilder.New()
        .AddState<Idle>()
        .Build();   // throws: missing StartIn<T>()
}
catch (MachineSetupException ex)
{
    Console.WriteLine(ex.Message);
}

This makes the "no current state" case impossible at runtime — GetCurrentState() will never return null on a freshly built machine, and the configured OnEntry of the initial state runs as part of Build().

Examples

For more usage patterns — entry/exit callbacks, transition rules, typed machines, pre-built instances, and IStatement states — see the unit tests under tests/Statement.Tests.

License

See License.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.2 94 5/25/2026
0.1.1 93 5/18/2026
0.1.0 94 5/17/2026
0.0.0-alpha.0.33 53 5/18/2026
0.0.0-alpha.0.28 47 5/17/2026