3
\$\begingroup\$

Context

I'm playing around the concept of a Finite State Machine. While I understand the basic principles behind it, I want to implement a simple, yet generic class which could be reused in the future projects.

Focus

The code below intentionally separates the StateMachineDefinition and StateMachineRuntime interfaces from the StateMachine class which implements them. This is done in order to distinct the initialState from the currentState of a given instance of the state machine. This also allows providing the minimum necessary information about the state machine when it's being constructed. In other words, this separation is on purpose.

What I want help with, are a few things I either don't like or don't know:

  1. Explicitly specifying ... | undefined to make the compiler happy is ugly! E.g.

    const currentStateAllowedTransitions: { [_ in Signal]?: State; } | undefined =
      this.transitions[this.currentState]
    

    and

    const newState: State | undefined = currentStateAllowedTransitions[signal];
    
  2. Manual field copying in StateMachine's constructor (from the definition provided) seems silly, especially knowing that StateMachine IS a StateMachineDefinition:

    this.states = stateMachineDefinition.states;
    this.initialState = stateMachineDefinition.initialState;
    this.finalStates = stateMachineDefinition.finalStates;
    this.transitions = stateMachineDefinition.transitions;
    
    this.currentState = stateMachineDefinition.initialState;
    
  3. Probably, not really a question for CodeReview. In my code the finalStates are not really used (only allow checking whether the state machine isInFinalState()). What is it used in real life situations? Should I drop it altogether? Or should I change it?

  4. Any other advice regarding code readability, maintainability, idiomatic TypeScript, etc are very welcome too.

The code

State machine's interfaces

// tslint:disable:no-console

export interface StateMachineDefinition<State extends string, Signal extends string> {
  states: State[];
  initialState: State;
  finalStates: State[];
  transitions: { [state in State]?: { [signal in Signal]?: State; } };
}

export interface StateMachineRuntime<State extends string, Signal extends string> {
  currentState: State;
  process(signal: Signal): void;
  isInFinalState(): boolean;
}

type Stream<Signal> = Signal[];

State machine itself

export class StateMachine<State extends string, Signal extends string>
  implements
    StateMachineDefinition<State, Signal>,
    StateMachineRuntime<State, Signal> {

  states: State[] = [];
  initialState: State;
  finalStates: State[] = [];
  transitions: { [state in State]?: { [signal in Signal]?: State; } };

  currentState: State;

  constructor(stateMachineDefinition: StateMachineDefinition<State, Signal>) {
    this.states = stateMachineDefinition.states;
    this.initialState = stateMachineDefinition.initialState;
    this.finalStates = stateMachineDefinition.finalStates;
    this.transitions = stateMachineDefinition.transitions;

    this.currentState = stateMachineDefinition.initialState;

    console.info(`Initialized state machine.\n${ JSON.stringify(this, null, 2) }`);
  }

  process(signal: Signal): void {
    const currentStateAllowedTransitions: { [_ in Signal]?: State; } | undefined =
      this.transitions[this.currentState];

    if (currentStateAllowedTransitions == null) {
      throw new Error(`No transitions are allowed from '${this.currentState}'`);
    }

    const newState: State | undefined = currentStateAllowedTransitions[signal];
    if (newState == null) {
      throw new Error(`No transition is allowed from '${this.currentState}' for '${signal}'`);
    }

    this.currentState = newState;
  }

  isInFinalState(): boolean {
    return this.finalStates.some(knownFinalState => knownFinalState === this.currentState);
  }

}

Example of consumption

type DeviceStates = 'off' | 'waiting' | 'sleeping';
type UserCommands = 'turnOn' | 'turnOff' | 'putToSleep' | 'awake';

const stateMachine = new StateMachine<DeviceStates, UserCommands>({
  states: ['off', 'waiting', 'sleeping'],
  initialState: 'off',
  finalStates: ['off'],
  transitions: {
    off: {
      turnOn: 'waiting',
    },
    waiting: {
      turnOff: 'off',
      putToSleep: 'sleeping',
    },
    sleeping: {
      awake: 'waiting',
    },
  },
});

console.warn(`====================================`);

const signals: Stream<UserCommands> = [ 'turnOn', 'putToSleep', 'awake', 'turnOff' ];

console.warn(`original state: '${ stateMachine.currentState }'`);
console.warn(`------------------------------------`);

signals.forEach(signal => {
  const state = stateMachine.currentState;

  stateMachine.process(signal);

  console.warn(`state '${ state }' + signal '${ signal }' => state '${ stateMachine.currentState }'`);
});

console.warn(`------------------------------------`);
console.warn(`result state: '${ stateMachine.currentState }'`);

Output

Initialized state machine.                               
{                                                        
  "states": [                                            
    "off",                                               
    "waiting",                                           
    "sleeping"                                           
  ],                                                     
  "finalStates": [                                       
    "off"                                                
  ],                                                     
  "initialState": "off",                                 
  "transitions": {                                       
    "off": {                                             
      "turnOn": "waiting"                                
    },                                                   
    "waiting": {                                         
      "turnOff": "off",                                  
      "putToSleep": "sleeping"                           
    },                                                   
    "sleeping": {                                        
      "awake": "waiting"                                 
    }                                                    
  },                                                     
  "currentState": "off"                                  
}                                                        
====================================                     
original state: 'off'                                    
------------------------------------                     
state 'off' + signal 'turnOn' => state 'waiting'         
state 'waiting' + signal 'putToSleep' => state 'sleeping'
state 'sleeping' + signal 'awake' => state 'waiting'     
state 'waiting' + signal 'turnOff' => state 'off'        
------------------------------------                     
result state: 'off'                                      
\$\endgroup\$
  • \$\begingroup\$ Not enough for a full answer: 1. Use type State = string; and type Signal = number; instead of redefining them every time. 2. Use enums for DeviceStates and UserCommands. \$\endgroup\$ – Benjamin Philippe Dec 4 '17 at 16:10
  • \$\begingroup\$ @BenjaminPhilippe I did try to use types but was getting compilation errors. So, feel free to post a full answer which shows how to apply both of your suggestions. As far as using number for Signal type goes, I'd not use that because string is a less restrictive interface. I don't want to limit clients to using numeric signals only... \$\endgroup\$ – Igor Soloydenko Dec 4 '17 at 19:32
  • \$\begingroup\$ Yeah, after reading your code I forgot Signal was supposed to be a string. To use type, just type Something = number; and var thing: Something = 0;. \$\endgroup\$ – Benjamin Philippe Dec 4 '17 at 20:11
  • \$\begingroup\$ @BenjaminPhilippe I know how to define a type. I don't understand how to apply type here to improve my code. I encourage you to write an answer (if you have time and desire of course). That will help me understand the improvement. So far, my attempts result in either getting compilation errors, or repeating similar code with no visible gains. This file in my GitHub repo can work as a starting point for you. It exactly matches code in my question: github.com/another-guy/playground/blob/master/src/… \$\endgroup\$ – Igor Soloydenko Dec 4 '17 at 21:36
  • \$\begingroup\$ @BenjaminPhilippe Don't want to be "pushy"... I just wanted to say that the topic of type thing and the way it works with TS type inference is not widely understood in the team I work with. We would really appreciate your input on this. \$\endgroup\$ – Igor Soloydenko Dec 4 '17 at 21:41

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.