eonarheim / TypeState

A strongly typed finite state machine for TypeScript
http://eonarheim.github.io/TypeState/example/
BSD 2-Clause "Simplified" License
272 stars 29 forks source link

Add data to states #12

Open tp opened 8 years ago

tp commented 8 years ago

This is more of a question/feature request (or at least basis for discussion of such):

Is there any standard way to add data to a state, or would you be interested in discussing such a feature?

I think in many business cases it would be useful to attach some data to a state as opposed to creating a multitude of individual states (which not practical if there are many "sub-states").

Sadly TypeScript does not support data in enum cases, so I don't see a straightforward way yet to implement this in TypeScript/TypeState. In Swift or Rust it is a core feature to add data to enum cases, so under such circumstances no special casing would be needed.

An example of a state machine that would be more useful with added data would be:

enum AirplaneBoarding {
  case Pending
  case Ongoing(peopleBoarded: number)
  case Finished
}

Do you have any implementation hints on how to approach something like the above? Or is this something that should not attempted when working with FSMs for some reason?

stefnotch commented 5 years ago

I'm currently working on a fork that can do something like that while keeping all the magical type checking.

So far, the main differences are that you use a class instead of an enum

class Elevator {
   DoorsOpened = {a:1};
   DoorsClosed = {b: 3};
   Moving = "Henlo"
}

of which an instance has to be passed to the constructor

var fsm = new typestate.FiniteStateMachine(new Elevator(), "DoorsClosed");

and instead of writing Elevator.DoorsOpened, you just write strings (which do have type checking!)

fsm.from("DoorsOpened").to("DoorsClosed");

https://github.com/stefnotch/TypeState/blob/master/example/example.ts

eonarheim commented 5 years ago

@stefnotch Cool! Open a PR, is there a way to preserve the existing behavior with to avoid a breaking change?

stefnotch commented 5 years ago

I think it should be possible to make the existing enum behaviour work, however not without a fair bit of work thanks to TypeScript's enum behaviour.

There is another breaking change as well. The callback now have an optional second parameter which is the current context. e.g.

 public on<U extends keyof T>(state: U, callback: (from?: keyof T, context?: T[U], event?: any) => any):

https://github.com/stefnotch/TypeState/blob/266fa325600dd0f5cd034235347aa02a01012ed4/src/typestate.ts#L77

This could be fixed by making the context the last parameter, however I think that usually you want the previous state + the context and not the event. Which is why I left the event as the last parameter.

eonarheim commented 5 years ago

@stefnotch I see, typescript enums are certainly cumbersome. I need some more time to think about this, given the length of time TypeState has relied on enums for defining states I want to be careful about how to proceed.

A couple options I see right now:

stefnotch commented 5 years ago

I looked into this a bit more. I have found a type that sort of works for classes and enums. However, it forces you to specify everything as "strings" instead of the typical Enum.DotNotation

enum Swag {
  Yo,
  Yolo,
  Nope
}

var x: keyof typeof Swag;
x = "Yolo";

class SwagClass {
  static "Yo": { hello: 1 };
}

var y: keyof typeof SwagClass;
y = "Yo";

I also looked into associating a context with an enum in a typesafe way. However, the approach I found isn't quite as pretty as I'd like

enum Swag {
    Yo,
    Yolo,
    Nope
  }
const SwagContext = {
  [Swag.Yo]: { hello: 1 },
  [Swag.Yolo]: "x",
  [Swag.Nope]: null
};

// Now the user is forced to specify something for `K`
class FiniteStateMachine<T, K> {
  context: K;
  constructor(startState: T, context?: K) {
    this.context = context;
  }
}

var fsm = new FiniteStateMachine<Swag, typeof SwagContext>(
  Swag.Yolo,
  SwagContext
);

// It works with type checking
fsm.context[Swag.Yo];
stefnotch commented 5 years ago

Regarding the sufficient type description, it might be possible to use conditional types to cover both cases (enums and classes).

Here is an example of conditional types.

type ConditionalTest<T> = T extends object ? number : string;

enum Swag {
  Yo,
  Yolo,
  Nope
}
let y: ConditionalTest<Swag>; // string

class SwagClass {}
let x: ConditionalTest<SwagClass>; // number