Xstate: [Feature] Serializable guard (string conditions)

Created on 8 Feb 2018  ·  22Comments  ·  Source: davidkpiano/xstate

Description:

Hi,
I've been thinking about serializable guard (i'm loading my statechart from server).
Does Function object (mdn link) could work for that ?

usage of Function :

const add = new Function ("a", "b", "return a + b")

add(2, 3)
// --> 5

Potential implementation:

The implementation could look like :

// ...
const condFn = !cond
  ? () => true // we can go forward if we don't have guard
  : typeof cond === 'string'
    ? new Function('extendedState', 'eventObject', cond) // create the function if cond is a string
    : cond; // use directly otherwise

if (condFn(extendedState, eventObject)) {
// ...

usage of cond :

const searchMachine = Machine({
  initial: 'idle',
  states: {
    idle: {
      on: {
        SEARCH: {
          searching: {
            // only transition to 'searching' if cond is true
            cond: "return extendedState.canSearch && eventObject.query && eventObject.query.length > 0"
          }
        }
      }
    },
    searching: {
      onEntry: ['executeSearch']
      // ...
    },
    searchError: {
      // ...
    }
  }
});

Advantage

  • simple to implement
  • doesn't have to create something custom
  • good browser support
  • keep function working

Drawback

  • implicit call of return inside the string.
  • can't change the extendedState & eventObject variable name.
  • can't guaranty user doesn't call an global object (example : return window.foo > 3).
enhancement

Most helpful comment

I just wanted to put in a few words that I think providing guards when "setting up" the machine would be a good idea. Independently of this thread (unless my brain's subconscious self has managed to connect to the Internet) I wrote down the following:

// simple state machine x, y, with event e for transition (x→y) guarded by the condition 'empty'
const definition = {
  initial: 'x',
  states: {
    x: {
      on: {
        e: {
          y: {
           cond: 'empty'
          }
        }
      }
    },
    y: {}
  }
};
// empty is provided when constructing the machine,
// so the resulting machine ends up referencing the right guards at the right places.
const machine = Machine(definition, {guards: {empty: s => s.length == 0}})

machine.initialState would be 'x', and machine.transition('x', 'e', '') would call the guard function.

My only addition would be to support boolean logic in the guard definitions themselves: so e.g. cond: "!empty" would be allowed and even cond: "!foo && bar && (baz || joe)"

All 22 comments

I like this and have been thinking of how to serialize guards so that e.g. conditionals can be stored in a database, or interpreted by other languages.

We can probably omit return as a sugar (so it reads just like a traditional statechart/UML diagram); ideally, conditional statements should be simple expressions (as they tend to be in normal application code).

I'll wait to hear what others think about this. 👍

Guards are tricky. The way I understand them, they're supposed to be able to ask things about the world that the statechart might not be privy to. I also don't like that I _have_ to pre-compute the data that _might_ be needed by a guard when I call transition.

When I first learned about xstate I had an idea that a guard check could be handled by exposing the "guard check" as the response of transition — i.e. that transition was essentially asking for a guard check, and it would pass the _string_ of the guard check back to the caller, saying, essentially: "please give me the boolean value of this guard".

states: {
  A: {
    on: {
      event: {
        B: { cond: "foo" }
      }
    }
  },
  B: {}
}

Calling transition("A", "event") would yield an object representing the original state, which "was about to transition" to state B, except it asks: "what is foo"? I would then have to pass in the value of "foo" somehow: Perhaps transition("A", "event", undefined, {foo: true})

Anyhow, for cond: "extendedState.canSearch && eventObject.query && eventObject.query.length > 0" it would be up to the caller to determine if it is true or false, implement boolean logic or punt and just eval it.

But my own feeling about this idea is that it's over-engineering the solution; it becomes mighty chatty if every guard requires a new call to transition. A simple string of a function body works for me.

You could consider defining a formal language, and not allow arbitrary JavaScript, but simple boolean logic.

You could consider defining a formal language, and not allow arbitrary JavaScript, but simple boolean logic.

This is what I want to aim for. To my understanding, a "guard" is just an abstract concept that splits actions into mutually exclusive subsets. That is, let's say you had an data.x > 100 guard for CLICK.

This is equivalent to having two actions, fired when the conditions are met:

  • CLICK_WHEN_X_IS_LTE_100: 'somewhere'
  • CLICK_WHEN_X_IS_GT_100: 'somewhereElse'

Of course, nobody would write the above (hopefully). The point is to illustrate that this "subset" separation of actions is purely boolean, so some sort of common boolean logic expression would be ideal. (And we can statically analyze it!)

I also like having a string like @mogsie is suggesting, e.g., 'condition_is_met': 'foo' and then you'd map 'condition_is_met' to some predicate function (implementation detail). With that, you could also say that the string 'data.x > 100' also maps to some predicate function as well 😝

Are there established standards for writing boolean expressions? I wonder what SCXML uses.

Mapping 'data.x > 100' to a function is just horrible. I know it was a joke, but I have an anecdote: I tried writing a Statechart debugger and quickly ended up right here: I made a checkbox for each unique guard expression. I ended up with the following guards:

  • foo
  • !foo
  • foo && bar
  • !foo || (bar && !baz)
  • etc...

It was impossible to use, I'd have to do lots of boolean logic in my head to evaluate each one to know how the guards were when trying to debug an issue in my statechart.

I quickly ditched that idea and parsed the guard using some rules and ended up with the three checkboxes I needed: foo, bar and baz and had the debugger _evaluate_ the guards.

It's simple for booleans; question is if it should support ranges, e.g. x > y.

AFAIK, scxml leaves it as an implementation detail, up to the implementation. This of course is an interoperability problem, since you end up with JS SCXML files vs Java SCXML files vs C# SCXML files and so on.

This goes hand-in-hand with the transition array proposal in #43, since each subsequent condition implicitly represents the union of the previous condition's complement and the current condition.

Okay, so how should we do this as an MVP? Should we just support string conditions in general and leave the interpretation up to the interpreter?

When you say leave the interpretation to the interpreter, that mean we provide an interface to execute guard condition when we create the instance of the machine ?

I think of something like that :

interface GuardInterpreter {
  isValid(guard: string, state: State, extendedState?: any): boolean;
  check(guard: string, state: State, extendedState?: any): boolean;
}

and constructor

// constructor machine
 constructor(
    public config:
      | SimpleOrCompoundStateNodeConfig
      | StandardMachineConfig
      | ParallelMachineConfig,
     public guardInterpreters: Array<GuardInterpreter>
  ) // ....

On the execution, we check which one is valid, stop on the first valid and and launch the guard.
Is this what you think of ? I like the flexibility about interpretation of the conditions

What happen to the function based guard ? Should it be deprecated, still supported or just remove ?

When the change is ok about guard string conditions, I would like to help if this is ok for you.

Something also to consider - I'd like to serialize the "in _someState(s)_" conditional since, especially for parallel machines, it looks like it's going to be a common use-case (I've already ran into this use-case multiple times, and when generating tests, it's a pain to stuff this condition in a function instead of having it be declarative).

Example:

The statechart could look like this:

Machine({
  key: 'Y',
  parallel: true,
  states: {
    A: {
      initial: 'B',
      states: {
        B: { on: { alpha: 'C' } },
        C: {
          on: {
            beta: {
              // 'in' is always relative to parent
              B: { in: 'D.G' }
            }
          }
        }
      }
    },
    D: {
      initial: 'F',
      states: {
        F: { on: { alpha: 'G', mu: 'E' } },
        G: { on: { delta: 'F' } },
        E: { on: { gamma: 'G' } }
      }
    }
  }
});

The 🆕 notation here would be B: { in: 'D.G' }. Since in can only apply to a parallel machine, it makes sense that it is relative to B's parent state, so the rules in #52 would still apply.

Here's the parallel (no pun intended) to SCXML:

The SCXML processor must define an ECMAScript function named 'In()' that takes a stateID as its argument and returns 'true' if and only if that state is in the current state configuration, as described in 5.9.1 Conditional Expressions. Here is an example of its use, taken from G.3 Microwave Example (Using parallel)

        <state id="idle">
          <transition cond="In('closed')" target="cooking"/>
        </state>

        <state id="cooking">
          <transition cond="In('open')" target="idle"/>

          <!-- a 'time' event is seen once a second -->
          <transition event="time">
            <assign location="timer" expr="timer + 1"/>
          </transition>
        </state>

In the above example, closed and open are states in a different orthogonal state (i.e., not the same one as idle and cooking).

Some more thoughts:

  • { B: { in: 'D.G', cond: 'someCond' } } would be equivalent to someCond && in('D.G')
  • This also opens the possibility for defining multiple orthogonal states as a condition, e.g.:

    • { B: { in: { D: 'G', FOO: { BAR: 'BAZ' } } } which would be satisfied if we're in the D.G and the FOO.BAR.BAZ states

Alternatively (or additionally), we could pass stateValue as the 3rd parameter for the guard:

{
  B: { cond: (data, event, stateValue) => matches('D.G', stateValue) }
}

Thoughts?

More info: this is how Amazon serializes their conditionals: https://states-language.net/spec.html#choice-state

(not sure we want to get that verbose, though)

Yes ! I like the in. That could be confusing to partially handle the guard with the in and let the rest for the interpreter. We need to do that as a InStateInterpreter that xstate could provide.

So the guard could be a string or an object. This is even more flexible if someone doesn't want to serialize with string but object, like amazon did (thank for the link, it's really interesting).

edit: array of object for amazon but we can change the guard for that if we really want to possibly handle a guard like amazon.

something like `string | object | object[]`

That mean :

  • if the machine is initialized without interpreter(s), we use a default [InStateInterpreter].
  • if the machine is initialized with interpreter(s), we need to choose if :

    • InStateInterpreter is always provided as an interpreter : [InStateInterpreter, ...customInterpreters] (I find that great to not have to think about)

    • or export InStateInterpreter and developer have to provide their custom interpreters with the InStateInterpreter if they want it.

The state as a third argument is great for me.

If i understand correctly :

  • data represent extendedState today so type is any
  • event is EventObject
  • state is StateValue

The interface of Interpreter with that change :

interface GuardInterpreter {
  isValid(guard: string | object, data: any, event:  EventObject, state: StateValue): boolean;
  check(guard: string | object, data: any, event:  EventObject, state: StateValue): boolean;
}

As for a default interpreter, that's being discussed in #50, however, the core xstate library will remain a pure transition function in order to ensure flexibility and not be opinionated (and also to keep the library size small). With that said, doing something like import { Interpreter } from 'xstate/interpreter' should be straightforward in the future.

In the GuardInterpreter interface, what would isValid report? That the conditional expression is valid?

Also, I found this: http://jsonlogic.com seems to be fairly popular, and representation of boolean/relational expressions as a binary tree, albeit not natural, seems robust enough.

Oh well I missed that one !

My thinking was only about "guard interpreter" in a way that we can provide a list of "guard interpreter".
Then isValid could be use to filter on the correct guard to execute.

As an exemple, if we have a InStateGuardInterpreter as describe before and a JsonLogicGuardInterpreter.

We provide a list of these guard interpreters [InStateGuardInterpreter, JsonLogicGuardInterpreter].

Then, when a transition occur, we check which "guard interpreter" should be use to execute the guard.

// for inState check
isValid(guard: string | object, data: any, event:  EventObject, state: StateValue): boolean {
  return typeof guard === "object"
}
// for jsonLogic check
isValid(guard: string | object, data: any, event:  EventObject, state: StateValue): boolean {
  return typeof guard === "string"
}

(this example is just provide as an exemple, the isValid is not tested)

That way, we can use more than one way to execute guard.

Perhaps i'm going too deep about what is really needed for this library. The advantage is that we can do anything we want. We can mix custom "guard interpreter" with the InStateGuardInterpreter provided by xstate and more. An implementation with JsonLogic link is really easy.

What i'm not sure about right now after reading ticket #50 : the list of guard should be provided to the interpreter discussed in #50 or to the machine.

example :

import { Machine, Interpreter } from 'xstate';

const myMachine = Machine({ ... });
const interpreter = Interpreter(myMachine);

// choice one
interpreter.guardInterpreter([InStateGuardInterpreter, JsonLogicGuardInterpreter])
// choice two
Machine({ ... }, [InStateGuardInterpreter, JsonLogicGuardInterpreter])

Hi, been following this thread and found this:
https://www.npmjs.com/package/serialize-javascript

It is a general purpose serialization library and not a statecharts/state machine DSL. And of course everything ends being just eval'd 🤷‍♂️. Probably something more constrained as http://jsonlogic.com is a better fit.

Also, thank you both for evangelizing FSMs and statecharts. We need more of this.

Just to add another data point to this discussion.

We've recently started using state machines in our app, and have built our own state machine runner using Redux Sagas. We wanted to add support for nested and parallel states, and rather than do the work myself, I thought it would be nice to switch out the core of our runner for xState and have our code just be a thin interpreter around it.

Unfortunately I don't think that is going to be possible, as our cond functions are Redux Sagas, and as such are able to use selectors to pull in the external state they want. But in xState cond functions are run by the Machine, so won't be run as Sagas, and so won't be able to pull in state using selectors.

It would be great if we could define cond functions the same way we do for actions, and just pass in string name/reference to the function.

xState could then pass the function reference to us, we could call it as part of the Saga, and then let xState know the result. This would have the added benefit of keeping the state description serialisable and keeping parity with the way actions works.

@karl That's going to come in 3.2 - next on my list 📃

What I'm thinking is that a statechart defined with string conditions will have to be "set up" with those functions, otherwise it'll throw an error (of course, otherwise the state will be nondeterministic).

How's this for an API?

const machine = Machine({ ... }, {
  guards: {
    isFoo: (fullState, event) => fullState.foo === 'bar',
    isBar: myBarConditional
  }
});

Then the machine can be defined with cond: 'isFoo', etc.

But if it encounters a string condition 'isFoo' and there isn't a guard function set up for it, it'll throw an error.

With that API I’m still not clear on how I would run the functions as part of my Saga (so they would be able to use selectors to access state).

I haven’t thought it all through but I would have expected to be returned an array of function ids and then I would run those and return the results to the Machine wherein it would return me the new state.

I haven’t thought it all through but I would have expected to be returned an array of function ids and then I would run those and return the results to the Machine wherein it would return me the new state.

The problem is that wouldn't be a State instance, it would be more of a JunctionState or IntermediateState instance. So if we wanted to do that, we'd have to make a new method:

machine.conditionallyTransition(state, event);
// always returns JunctionState, instead of a plain State

Where JunctionState would have e.g., a conditionalValue property like:

{
  isFoo: { foo: 'bar' },
  isBaz: { foo: 'baz' }
  // etc.
}

And then you'd evaluate the actual value based on whether isFoo or isBar is true. Unless you have a better API idea? I'm not sure how this would look yet.

Yeah in my head I'd imagined something a bit like that.

Although I wasn't sure if you'd need to have two steps. One to get all the conditions, and a second one to do the actual transition. I wonder if the second step would be needed so that xstate can return you all the actions that you would need to call (e.g. actions for the transition, onEntry, onExit, etc).

const conditions = machine.getConditions(state, event);
const results = conditions.map(cond => cond()); // execute the cond functions however you want.
const newState = machine.transition(state, event, {}, results);

One thing I would say is to take any API suggests from me with a big grain of salt. I've only been using state machines for a couple of weeks so don't have much experience with them!

With that API I’m still not clear on how I would run the functions as part of my Saga (so they would be able to use selectors to access state).

@karl Do you have a demonstration of how this _would_ look normally? I still don't see why this wouldn't work:

const machine = Machine({ ... }, {
  guards: {
    isFoo: (fullState, event) => fullState.foo === 'bar',
    isBar: myBarConditional
  }
});

And then you can do this in redux-saga:

const fullState = yield select();
const currentState = fullState.app; // or wherever you're keeping the finite state

// This will properly evaluate the state with your custom guards
const nextState = machine.transition(currentState, event, fullState);

Your guards should _always_ be pure functions, so putting a yield select(...) in them doesn't make sense.

My brain has slowly come to the same conclusion!

I think we should be able to get the full state in the interpreter saga and pass it to the transition function. Then within a guard we could just call the selectors we want on that state.

const machine = Machine({ ... }, {
  guards: {
    isFoo: (fullState, event) => selectFoo(fullState) === 'bar',
    isBar: myBarConditional
  }
});

const runner = function*() {
  const fullState = yield select();
  const currentState = yield select(selectAppState);

  // This will properly evaluate the state with your custom guards
  const nextState = machine(currentState, event, fullState);
}

I was initially reluctant to do this as it means you can't use the usual yield select(xxx) that you would be using in actions, onEntry, and onExit, etc. Instead you would need to know that cond functions are treated differently.

But perhaps that is a blessing in disguise as letting cond functions run as full sagas would let them run asynchronously which could cause all kinds of pain 😱

@davidkpiano Thank you for taking the time to coach me through this!

I'm finding that state machines are great for tackling some of the inherent complexity in our code, but it's a learning curve to work out the best way of using them!

Yep, state machines (and especially statecharts) are huge learning curves with a lot of history behind them, but it's worth it!

I'm moving this to 3.3, there's still a lot to think about.

I just wanted to put in a few words that I think providing guards when "setting up" the machine would be a good idea. Independently of this thread (unless my brain's subconscious self has managed to connect to the Internet) I wrote down the following:

// simple state machine x, y, with event e for transition (x→y) guarded by the condition 'empty'
const definition = {
  initial: 'x',
  states: {
    x: {
      on: {
        e: {
          y: {
           cond: 'empty'
          }
        }
      }
    },
    y: {}
  }
};
// empty is provided when constructing the machine,
// so the resulting machine ends up referencing the right guards at the right places.
const machine = Machine(definition, {guards: {empty: s => s.length == 0}})

machine.initialState would be 'x', and machine.transition('x', 'e', '') would call the guard function.

My only addition would be to support boolean logic in the guard definitions themselves: so e.g. cond: "!empty" would be allowed and even cond: "!foo && bar && (baz || joe)"

Was this page helpful?
0 / 5 - 0 ratings