Finite State Machines – Declarative Application State Management

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:

  1. Define state machine via @kosStateMachine class decorator
  2. Automatic initialization – FSM starts with initial state immediately
  3. Handle transitions via @kosStateEntry and @kosStateExit method decorators
  4. Validate actions via @kosStateGuard method decorator
  5. Observable by default – integrates with @kosModel reactive 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
       └──────────────→ IDLE

Integration 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

  1. Start Simple: Begin with 3-4 states, add complexity only when needed
  2. Remove Terminal States: Transition directly back to initial state when possible
  3. Use Enums: Define states as enums for type safety and refactoring support
  4. Choose throwOnInvalid Wisely: false for UI interactions, true for internal invariants
  5. Visualize First: Draw state diagram before implementing
  6. Entry/Exit Handlers: Keep them side-effect focused (logging, events, notifications)
  7. 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 @kosStateEntry and @kosStateExit
  • Action validation via @kosStateGuard
  • Graceful error handling via throwOnInvalid pattern
  • 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