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:
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];
Manual field copying in
StateMachine
's constructor (from the definition provided) seems silly, especially knowing thatStateMachine
IS aStateMachineDefinition
:this.states = stateMachineDefinition.states; this.initialState = stateMachineDefinition.initialState; this.finalStates = stateMachineDefinition.finalStates; this.transitions = stateMachineDefinition.transitions; this.currentState = stateMachineDefinition.initialState;
Probably, not really a question for CodeReview. In my code the
finalStates
are not really used (only allow checking whether the state machineisInFinalState()
). What is it used in real life situations? Should I drop it altogether? Or should I change it?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'
type State = string;
andtype Signal = number;
instead of redefining them every time. 2. Use enums forDeviceStates
andUserCommands
. \$\endgroup\$ – Benjamin Philippe Dec 4 '17 at 16:10type
s 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 usingnumber
forSignal
type goes, I'd not use that becausestring
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:32Signal
was supposed to be astring
. To usetype
, justtype Something = number;
andvar thing: Something = 0;
. \$\endgroup\$ – Benjamin Philippe Dec 4 '17 at 20:11type
. I don't understand how to applytype
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:36type
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