Overview
The KOS Finite State Machine (FSM) system provides declarative, type-safe state management for application-specific business logic through four decorators: @kosStateMachine, @kosStateEntry, @kosStateExit, and @kosStateGuard. This eliminates 30+ lines of manual state management boilerplate while providing automatic validation, observable state tracking, and graceful error handling.
The Problem
Models with complex application-level state (pour operations, device workflows, connection states) traditionally require:
- Manual state property management and validation
- Scattered state checks throughout methods
- Helper methods like
updateIfCurrent()to prevent race conditions - Inconsistent state transition patterns
- Mixed concerns (state logic + business logic)
Example: A pour model managing IDLE → POUR_STARTING → POURING → IDLE typically needs ~80 lines of manual state tracking with state assignments, validation checks, and event publishing scattered throughout.
The Solution
FSM decorators manage application state declaratively:
- Define state machine via
@kosStateMachineclass decorator - Automatic initialization – FSM starts with initial state immediately
- Handle transitions via
@kosStateEntryand@kosStateExitmethod decorators - Validate actions via
@kosStateGuardmethod decorator - Observable by default – integrates with
@kosModelreactive system
Core Decorators
@kosStateMachine – Class Decorator
Defines the state machine configuration for a model. The FSM initializes immediately during construction with the specified initial state.
Interface:
@kosStateMachine<TState, TEvent>(
config: {
initial: TState;
states: Record<TState, {
on?: Partial<Record<TEvent, TState>>;
}>;
},
options?: {
stateProperty?: string; // Default: 'currentState'
trackHistory?: boolean; // Default: false
throwOnInvalid?: boolean; // Default: true
}
)Example:
enum PourState {
IDLE = "IDLE",
POUR_STARTING = "POUR_STARTING",
POURING = "POURING",
POUR_CANCELING = "POUR_CANCELING"
}
type PourEvent =
| "START_POUR"
| "POUR_ACTIVE"
| "CANCEL_POUR"
| "POUR_CANCELED"
| "POUR_COMPLETED";
interface GenericPourModelImpl
extends KosStateMachineAware<PourState, PourEvent> {}
@kosModel(MODEL_TYPE)
@kosStateMachine<PourState, PourEvent>(
{
initial: PourState.IDLE,
states: {
[PourState.IDLE]: {
on: { START_POUR: PourState.POUR_STARTING },
},
[PourState.POUR_STARTING]: {
on: {
POUR_ACTIVE: PourState.POURING,
CANCEL_POUR: PourState.POUR_CANCELING,
POUR_COMPLETED: PourState.IDLE,
},
},
[PourState.POURING]: {
on: {
CANCEL_POUR: PourState.POUR_CANCELING,
POUR_COMPLETED: PourState.IDLE,
},
},
[PourState.POUR_CANCELING]: {
on: { POUR_CANCELED: PourState.IDLE },
},
},
},
{
throwOnInvalid: false, // Gracefully handle expected runtime scenarios
}
)
export class GenericPourModelImpl implements IKosDataModel {
// currentState automatically initialized to PourState.IDLE
// isFsmInitialized automatically true
// transition(), canTransition(), isInState() methods injected
}Injected Properties:
interface KosStateMachineAware<TState, TEvent> {
currentState: TState; // Always defined, observable
readonly allowedTransitions: TEvent[]; // Computed from current state
readonly isFsmInitialized: boolean; // Always true after construction
isTransitioning: boolean; // Observable transition flag
transition(event: TEvent): void; // Trigger state transition
canTransition(event: TEvent): boolean; // Check if transition valid
isInState(state: TState): boolean; // Check current state
stateHistory?: Array<{ // Optional history tracking
state: TState;
timestamp: Date;
event?: TEvent;
}>;
}@kosStateEntry – Method Decorator
Executes a method when entering a specific state. Entry handlers are called after the state transition completes.
Interface:
@kosStateEntry<TState>(state: TState)Example:
@kosStateEntry(PourState.POURING)
handlePouringStart(): void {
this.logger.info('Pour started');
// Notify companion models
getAllKosCompanionModels(this).forEach((model) => {
if (isPourAware(model)) {
model.onPourStart?.(this.id);
}
});
// Publish event
EventBus.publish(TOPIC_POUR_STARTED, {
pourTarget: this.id,
type: this.pourType,
});
}@kosStateExit – Method Decorator
Executes a method when exiting a specific state. Exit handlers are called before the state transition completes.
Interface:
@kosStateExit<TState>(state: TState)Example:
@kosStateExit(PourState.POUR_CANCELING)
handleCanceled(): void {
this.logger.info('Pour canceled');
// Notify companion models
getAllKosCompanionModels(this).forEach((model) => {
if (isPourAware(model)) {
model.onPourCancel?.();
}
});
// Publish cancel event
EventBus.publish(TOPIC_POUR_CANCEL, {
pourTarget: this.id,
});
}@kosStateGuard – Method Decorator
Validates that the FSM is in an allowed state before executing a method. When validation fails, behavior depends on throwOnInvalid setting.
Interface:
@kosStateGuard<TState>(options: {
allowedStates: TState[];
throwOnInvalid?: boolean; // Overrides class-level setting
})Behavior:
throwOnInvalid: true: Throws error if state invalid (for coding errors)throwOnInvalid: false: Logs warning and returns undefined without executing (for expected runtime scenarios)
Example:
@kosFuture()
@kosStateGuard({ allowedStates: [PourState.IDLE] })
public async pour(tracker?: string) {
// If called during active pour: logs warning, returns undefined (prevents double-pour)
// If called from IDLE: executes normally
this.transition("START_POUR");
if (!tracker) {
throw new Error("No tracker provided");
}
const result = await pourOnTarget(this.servicePath, tracker);
this.transition("POUR_ACTIVE");
if (result?.data?.endState) {
this.transition("POUR_COMPLETED");
}
return result;
}
@kosStateGuard({ allowedStates: [PourState.POUR_STARTING, PourState.POURING] })
public async cancelPour() {
// If called when already IDLE: logs warning, returns undefined (graceful)
// If called during pour: executes normally
this.transition("CANCEL_POUR");
await this.futureHandler.getFuture()?.cancelFuture();
this.transition("POUR_CANCELED");
}Design Patterns
throwOnInvalid: Error Handling Strategy
The throwOnInvalid option controls how the FSM handles invalid state transitions and guard violations:
throwOnInvalid: true – Use for coding errors
- Transitions that should never happen in correct code
- Indicates bugs that need to be fixed
- Throws exceptions to halt execution and surface the error
throwOnInvalid: false – Use for expected runtime scenarios
- User interactions that can happen at wrong times (button double-clicks)
- Race conditions between UI and business logic
- Events that arrive after state changes (pour completes before button release)
- Logs warning and prevents execution gracefully
Real-world example:
@kosStateMachine<PourState, PourEvent>(
{ /* config */ },
{ throwOnInvalid: false } // Expected runtime scenarios
)
export class GenericPourModelImpl {
@kosStateGuard({ allowedStates: [PourState.IDLE] })
public async pour(tracker?: string) {
// User double-clicks pour button while pour active
// Result: Logs warning, returns undefined, no double-pour
}
@kosStateGuard({ allowedStates: [PourState.POUR_STARTING, PourState.POURING] })
public async cancelPour() {
// User releases button after pour already completed
// Result: Logs warning, returns undefined, graceful handling
}
}Simplifying State Machines
Avoid unnecessary terminal states – transition directly back to initial state when operation completes:
Before (Complex):
states: {
IDLE: { on: { START: 'STARTING' } },
STARTING: { on: { ACTIVE: 'ACTIVE' } },
ACTIVE: { on: { COMPLETE: 'COMPLETE' } },
COMPLETE: { on: { RESET: 'IDLE' } }, // Unnecessary terminal state
}
// Requires explicit reset
this.transition('COMPLETE');
this.transition('RESET');After (Simplified):
states: {
IDLE: { on: { START: 'STARTING' } },
STARTING: { on: { ACTIVE: 'ACTIVE' } },
ACTIVE: { on: { COMPLETE: 'IDLE' } }, // Direct to IDLE
}
// Clean, single transition
this.transition('COMPLETE');State Machine Visualization
Visual representation helps communicate state flow:
IDLE
│
│ START_POUR
↓
POUR_STARTING ──→ POUR_CANCELING
│ │
│ POUR_ACTIVE │ POUR_CANCELED
↓ ↓
POURING ──────────────→ IDLE
│ ↑
│ CANCEL_POUR │
└──→ POUR_CANCELING ───┘
│
│ POUR_COMPLETED
└──────────────→ IDLEIntegration Patterns
FSM + @kosFutureAware
FSMs work naturally with async operations via @kosFutureAware:
@kosModel(MODEL_TYPE)
@kosLoggerAware()
@kosFutureAware()
@kosStateMachine<PourState, PourEvent>(/* config */)
export class GenericPourModelImpl
implements IKosDataModel, FutureContainer {
futureHandler: FutureHandler;
@kosFuture()
@kosStateGuard({ allowedStates: [PourState.IDLE] })
public async pour(tracker?: string) {
// State guard validates before Future starts
this.transition("START_POUR");
// Future tracks async operation
const result = await pourOnTarget(this.servicePath, tracker);
this.transition("POUR_ACTIVE");
return result;
}
@kosTopicHandler({ topic: TOPIC_POUR_PROGRESS })
handlePourProgress(event: PourProgressEvent): void {
// State-aware progress handling
if (this.isInState(PourState.POURING)) {
if (event.endState) {
this.transition("POUR_COMPLETED");
}
}
}
}FSM + @kosTopicHandler
Handle events based on current state:
@kosTopicHandler({ topic: "device.connection.changed" })
handleConnectionChange(payload: ConnectionPayload): void {
if (this.isInState(ConnectionState.CONNECTING)) {
if (payload.connected) {
this.transition("CONNECTED");
} else {
this.transition("CONNECT_FAILED");
}
}
}FSM + Companion Models
Notify companion models during state transitions:
@kosStateEntry(PourState.POURING)
handlePouringStart(): void {
// Notify all companion models
getAllKosCompanionModels(this).forEach((model) => {
if (isPourAware(model)) {
model.onPourStart?.(this.id);
}
});
}Real-World Examples
Pour Operations (generic-pour-model)
Complete pour operation state management with graceful error handling:
enum PourState {
IDLE = "IDLE",
POUR_STARTING = "POUR_STARTING",
POURING = "POURING",
POUR_CANCELING = "POUR_CANCELING"
}
type PourEvent =
| "START_POUR"
| "POUR_ACTIVE"
| "CANCEL_POUR"
| "POUR_CANCELED"
| "POUR_COMPLETED";
@kosModel(MODEL_TYPE)
@kosLoggerAware()
@kosFutureAware()
@kosStateMachine<PourState, PourEvent>(
{
initial: PourState.IDLE,
states: {
[PourState.IDLE]: {
on: { START_POUR: PourState.POUR_STARTING },
},
[PourState.POUR_STARTING]: {
on: {
POUR_ACTIVE: PourState.POURING,
CANCEL_POUR: PourState.POUR_CANCELING,
POUR_COMPLETED: PourState.IDLE,
},
},
[PourState.POURING]: {
on: {
CANCEL_POUR: PourState.POUR_CANCELING,
POUR_COMPLETED: PourState.IDLE,
},
},
[PourState.POUR_CANCELING]: {
on: { POUR_CANCELED: PourState.IDLE },
},
},
},
{
throwOnInvalid: false, // Expected runtime scenarios
}
)
export class GenericPourModelImpl implements IKosDataModel {
@kosStateEntry(PourState.POURING)
handlePouringStart(): void {
this.logger.info('Pour started');
EventBus.publish(TOPIC_POUR_STARTED, { pourTarget: this.id });
}
@kosStateExit(PourState.POUR_CANCELING)
handleCanceled(): void {
this.logger.info('Pour canceled');
EventBus.publish(TOPIC_POUR_CANCEL, { pourTarget: this.id });
}
@kosFuture()
@kosStateGuard({ allowedStates: [PourState.IDLE] })
public async pour(tracker?: string) {
try {
this.transition("START_POUR");
if (!tracker) {
throw new Error("No tracker provided");
}
const result = await pourOnTarget(this.servicePath, tracker);
this.transition("POUR_ACTIVE");
if (result?.data?.endState) {
this.transition("POUR_COMPLETED");
}
return result;
} catch (e) {
this.logger.error(e);
if (this.currentState !== PourState.IDLE) {
this.transition("POUR_COMPLETED");
}
throw e;
}
}
@kosStateGuard({ allowedStates: [PourState.POUR_STARTING, PourState.POURING] })
public async cancelPour() {
this.transition("CANCEL_POUR");
await this.futureHandler.getFuture()?.cancelFuture();
this.transition("POUR_CANCELED");
}
@kosTopicHandler({ topic: TOPIC_POUR_PROGRESS })
handlePourProgress(event: PourProgressEvent): void {
if (this.isInState(PourState.POURING)) {
if (event.endState) {
this.transition("POUR_COMPLETED");
}
}
}
}Connection Management
enum ConnectionState {
DISCONNECTED = "DISCONNECTED",
CONNECTING = "CONNECTING",
CONNECTED = "CONNECTED",
RECONNECTING = "RECONNECTING"
}
type ConnectionEvent =
| "CONNECT"
| "CONNECTION_ESTABLISHED"
| "DISCONNECT"
| "CONNECTION_LOST"
| "RECONNECT";
@kosStateMachine<ConnectionState, ConnectionEvent>(
{
initial: ConnectionState.DISCONNECTED,
states: {
[ConnectionState.DISCONNECTED]: {
on: { CONNECT: ConnectionState.CONNECTING },
},
[ConnectionState.CONNECTING]: {
on: {
CONNECTION_ESTABLISHED: ConnectionState.CONNECTED,
DISCONNECT: ConnectionState.DISCONNECTED,
},
},
[ConnectionState.CONNECTED]: {
on: {
DISCONNECT: ConnectionState.DISCONNECTED,
CONNECTION_LOST: ConnectionState.RECONNECTING,
},
},
[ConnectionState.RECONNECTING]: {
on: {
CONNECTION_ESTABLISHED: ConnectionState.CONNECTED,
DISCONNECT: ConnectionState.DISCONNECTED,
},
},
},
},
{ throwOnInvalid: false }
)
export class ConnectionModel {
@kosStateEntry(ConnectionState.CONNECTING)
async handleConnecting(): Promise<void> {
try {
await this.deviceService.connect();
this.transition("CONNECTION_ESTABLISHED");
} catch (e) {
this.logger.error("Connection failed", e);
this.transition("DISCONNECT");
}
}
@kosStateGuard({ allowedStates: [ConnectionState.DISCONNECTED] })
public async connect(): Promise<void> {
this.transition("CONNECT");
}
}Best Practices
When to Use FSM
✅ Good Use Cases:
- Operations with clear state progression (pour, dispense, connect)
- Workflows with validation requirements at each step
- State machines with 3-7 states (sweet spot for complexity)
- Operations where invalid state calls are expected (user interactions)
❌ Avoid FSM For:
- Simple boolean flags (loading, enabled, visible)
- State machines with 2 states (use boolean instead)
- Overly complex machines (10+ states – consider decomposition)
- State that’s purely computed from other values
Design Guidelines
- Start Simple: Begin with 3-4 states, add complexity only when needed
- Remove Terminal States: Transition directly back to initial state when possible
- Use Enums: Define states as enums for type safety and refactoring support
- Choose throwOnInvalid Wisely:
falsefor UI interactions,truefor internal invariants - Visualize First: Draw state diagram before implementing
- Entry/Exit Handlers: Keep them side-effect focused (logging, events, notifications)
- Guard Guards: Use guards for validation, not business logic
Type Safety
Always use TypeScript enums and interface merging for maximum type safety:
// Define state enum
enum PourState {
IDLE = "IDLE",
POURING = "POURING",
}
// Define event union
type PourEvent = "START" | "COMPLETE";
// Merge interface for type hints
interface ModelImpl extends KosStateMachineAware<PourState, PourEvent> {}
// Apply decorator with types
@kosStateMachine<PourState, PourEvent>(/* config */)
export class ModelImpl {
// this.currentState is PourState (not string)
// this.transition() accepts only PourEvent (not any string)
}Summary
The KOS FSM system provides:
- Declarative state management via
@kosStateMachine - Automatic initialization with always-defined
currentState - Lifecycle hooks via
@kosStateEntryand@kosStateExit - Action validation via
@kosStateGuard - Graceful error handling via
throwOnInvalidpattern - Observable integration with existing KOS reactive system
- Type safety through TypeScript generics and interface merging
By replacing manual state management with FSM decorators, you eliminate boilerplate, improve maintainability, and reduce bugs – all while maintaining the flexibility to handle expected runtime scenarios gracefully.
Next Steps
- See KOS Decorator Pattern for related decorators
- See Building Models for model fundamentals
- Explore real implementations:
generic-pour-model.ts,nozzle-model.ts
