Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Types Roadmap #61

Open
8 tasks
devanshj opened this issue Jul 26, 2021 · 26 comments
Open
8 tasks

Types Roadmap #61

devanshj opened this issue Jul 26, 2021 · 26 comments

Comments

@devanshj
Copy link
Contributor

devanshj commented Jul 26, 2021

  • Provide a hack-free safe degraded version of types. Because the currect depend on some likely typescript non-guarantees that might break, So if some users would like to trade safety over precise types and developer experience they can use that version. And we can also be less worried of making workarounds because we have already warned "Hey this might break" :P
    Two ways of going about this -

    • @cassionzen/usestatemachine/safe (temporary name)
    • declare module "@cassionzen/usestatemachine" {
        interface TypeOptions {
          safe: true
        }
      }
      Alternative names to "safe" - workaroundFree, hackFree,
      Current workarounds - InferNarrowestObject which is almost same as this and Definition.FromTypeParameter (TODO: document what's happening here)

    For milestone v1.0.0

  • Support guard's that are actually typed as type guards. Meaning this should work -

    const eventHasFoo = (state: { event: { foo?: string | undefined  } }): state is { event: { foo: string } } =>
      typeof state.event.foo !== "undefined"
    // I always wanted to publish a library provides authoring predicates that infer guards, which I might :P
    // Meaning with that library the above can be replaced by something like
    // let eventHasFoo = P.doesHave("event.foo", P.isNotUndefined)
    // and if they write it in the machine itself (which they should) they'd also get completions and errors via contextual inferrence.
    
    useStateMachine({
      schema: { events: { X: t<{ foo?: string | undefined }> } },
      initial: "a",
      states: {
        a: { on: { X: { target: "b", gaurd: eventHasFoo } },
        b: {
          effect: ({ event }) => {
            let x: string = event.foo
            // without `gaurd: eventHasFoo` foo would have been `string | undefined`
          }
        }
      }
    })

    It's great the signature is ({ context, event }) => ... instead of (context, event) => ... like xstate, because latter is principally incorrect and consequently has some cons

    For milestone v1.1.0

  • Provide (probably opt-in) linting

    • noInitialToDeadStateNodes
      // needs to be written once per (tsconfig) project
      declare module "@cassionzen/usestatemachine" {
        interface TypeOptions {
          noInitialToDeadStateNodes: true
        }
      }
      
      useStateMachine({
        initial: "b", // Error(noInitialToDeadStateNodes): "b" is a dead state node
        states: {
          a: { on: { X: "b" } },
          b: {}
        }
      })
    • noNonExhaustiveEventsSchema -
      useStateMachine({
        schema: {
          events: { // Error(noNonExhaustiveEventsSchema): `$$exhaustive` not set to `true`
            X: t<{ foo: string }>()
          }
        }
      })
    • noSchemalessDefintion
      useStateMachine({ // Error(noSchemalessDefintion): `schema` is not set
        initial: "a",
        states: { a: {} }
      })
    • more? I don't use state machines so I'm not sure what else we could come up with haha

    For milestone v1.2.0

  • more?

@devanshj
Copy link
Contributor Author

@cassiozen I want to provide a safe version in v1.0.0 itself. Which one of the two options you like? And what should we name it? "safe" is very ambiguous.

@cassiozen
Copy link
Owner

What if we flip? We make "safe" the default version and create an "edge" version form more experienced/adventurous users?

@cassiozen
Copy link
Owner

more?

We need nested states - hierarchical/recursive states are the next big feature I want to add.

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

What if we flip? We make "safe" the default version and create an "edge" version form more experienced/adventurous users?

We could do that but the problem is that these features to the users are not "edge" but really basic and beginner-friendly, for TypeScript it's "edge".

What I mean when I say "really basic and beginner-friendly"? Example -

useStateMachine({
  schema: { events: { X: t<{ a: number }>(), Y: t<{ a: number }>(), Z: t<{ b: number }>() } },
  initial: "a",
  states: {
    a: { on: { X: "c" } },
    b: { on: { Z: "a" } },
    c: {
      entry: ({ event }) => {
        let x = event.a
        // event here is narrowed down to { type: "X", a: number } | { type: "Y", a: number }
      }
    }
  },
  on: { Y: "c" }
})

It's only because of txstate-like types we can do that narrowing, with the "safe" version beginners would go "Umm why does it not allow accessing property a in event, the compiler says it could not be present there? The compiler says the event can be { type: "Z", b: number } but that's not true!". Here they are right and the types are wrong. In fact only experienced users will be able to deal with "safe" version because they'd be more confident than the compiler.

So my suggestion would be to not flip it.

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

We need nested states - hierarchical/recursive states are the next big feature I want to add.

Yep sure. The "more?" was only for more linter rules haha. Will add another top-level more xD

Also I'm thinking of this roadmap to only include typelevel features meaning once we have runtime hierarchical states it's obvious the types will have to support that. So it's not a "type-level" feature but rather a "runtime-level" feature

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

@davidkpiano @Andarist Yall should keep an eye on what I'm doing here :P because all these things would eventually come to txstate too ;)

@cassiozen
Copy link
Owner

The "more?" was only for more linter rules haha

I think these lint rules already go a long way. I can't think of any others. The "more" that I referred to was for the 1.2.0 release (or later) 😁

So it's not a "type-level" feature but rather a "runtime-level" feature

Very true. The types need to support that, and I'll work on the "runtime" code.

@cassiozen
Copy link
Owner

Regarding the "safe" version: I'm having an internal debate about it. My initial thought is that it would be confusing for a beginner to understand why a "safe" version exists.

As an exercise: What's the worst that can happen? A new version of TypeScript breaks useStateMachine?

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

What's the worst that can happen? A new version of TypeScript breaks useStateMachine?

Yep that. Or to be precise, the users using useStateMachine would have their builds failing when they upgrade TypeScript. And the worst is this happens and we don't have any workarounds to commit and publish a new version of useStateMachine.

In that case the users would have to switch to safe version (and maybe we'll have to make edge same as safe and publish) and the existing code will have to be edited with assertions. We could provide a few helpers that make it a little easier. So they'd have to do something like...

effect: ({ event }) => {
  let x = (event as XEvent | YEvent).a
}

Or with helpers...

effect: ({ event }) => {
  assertEventType(event, ["X", "Y"])
  let x = event.a
}

In theory one could write a codemod for this but would be quite a task :P

Edit: Rethinking about the codemod thing - I think it could be actually be a thing we can provide that edits your edge code to make you able to degrade to safe, in that case users would use edge version with confidence as they'd know the codemod has their back. Wdyt? Is this something worth exploring?

@cassiozen
Copy link
Owner

Let's go with the safe version then. Let's call it "boring"?
As in "@cassionzen/usestatemachine/boring"

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

Ahahaha xD well... It's kinda better than safe at least, but sounds way too informal to me. Maybe "lite-typed" or something like that?

Other options that come to mind (will keep editing): imprecise-types, imprecisely-typed, meagerly-typed, degraded-types, safe-types, hackfree-types

@cassiozen
Copy link
Owner

I think "light-typed" is the best we could come up with

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

Sounds good. We can always change it last minute if we come up with something better ;)

How about lightly-typed tho? light-typed feel grammatically incorrect to say. Or even light-types would work. And how about "lite" instead of "light", I think the former is more popular? (mobx-react-lite, caniuse-lite come to mind)

@cassiozen
Copy link
Owner

I don't really care if it's "lite" or "light" - both works for me.

lightly-typed sounds good.

@devanshj
Copy link
Contributor Author

devanshj commented Jul 26, 2021

I think I like "lite-types" more. Wanna go with that or "lightly-typed"? I don't have a strong preference tho, just lightly-typed has more syllables :P

But then I think lite-types in not an adjective argh xD idk anymore whatever works for me

@cassiozen
Copy link
Owner

Final decision: lite-types

I like that it's short and I don't care whether it's grammatically correct or not.

@devanshj
Copy link
Contributor Author

Done, it's good!

@devanshj
Copy link
Contributor Author

let [state, send] = useStateMachine({
  schema: {
    events: {
      A: t<{}>()
    }
  },
  initial: "a",
  states: { a: {} }
})
send("A")

Should we allow this? The user has defined an event in schema but it uses it nowhere in the definition. Or this should be a lint rule? It could be they might have kept it for later so an error seems too restrictive to me, not sure tho.

@cassiozen
Copy link
Owner

It should be consistent with what happens if the user adds a context schema before adding the context initial value in the definition. Right now it's an error (if I'm not mistaken).

@devanshj
Copy link
Contributor Author

devanshj commented Jul 27, 2021

It does give an error right now but I don't see how the comparison is 1:1. But going with that and even generally it looks like a mistake which should give an error. Later we could add an allowExtraEventsInSchema rule if we want.

@cassiozen
Copy link
Owner

Yeah, agreed

@cassiozen
Copy link
Owner

cassiozen commented Jul 29, 2021

@devanshj how hard/quick would it be to make the state definition recursive in the type level? I have a decent idea of how I would implement it at "runtime" level - and I'm starting to play with the idea of including it on the final 1.0 release.

@devanshj
Copy link
Contributor Author

devanshj commented Jul 29, 2021

It's definitely doable but to answer your question you'd have to tell me what the runtime behavior is. Basically what happens when the machine in node a.b.c takes an event X (in attempt to transition it to node a.d.e) ?

In case of xstate, in nutshell (nutshell being the keyword here :P) this happens -

  1. A transition is selected by looking up for X from the innermost node to the root node: Check if a.b.c takes X? Yes, select. No, check if a.b takes X? Yes, select. No, check if a takes X? Yes, select. No, check if root takes X? Yes, select. No, return as no transitions were selected. And note here "takes X" means it should be present in on AND the gaurd (if present) should return true
    (let's say a.b takes X and it was selected)
  2. Exit all nodes starting from innermost to the least common ancestor (in our case a): exit a.b.c, exit a.b. Note this is assuming the transition is "internal", if it were external then the root would be the least common ancestor.
  3. Enter all nodes starting from the least common ancestor to the target node: enter a.d, enter a.d.e

(It's hilarious and ironic that I haven't used xstate even once, have written zero machines with it yet I know the spec quite well because of txstate 🤣)

If the behavior going to be same as this, well then it might take some considerable time and effort. Especially writing the tests, implementation might be written quicker.

And if the behavior is going to be simpler then might take less time and effort.

Also, I'm not an expert by any means nor do I have strong opinions about it but be careful when not following the spec because my guess is it's been developed by many heuristics that might not be obvious right off the bat. You might also want to check what Steve does with state-designer or even other libraries for that matter.

So yeah the question is this: what happens when the machine in node a.b.c takes an event X?

@cassiozen
Copy link
Owner

Also, I'm not an expert by any means nor do I have strong opinions about it but be careful when not following the spec because my guess is it's been developed by many heuristics that might not be obvious right off the bat.

Yes, absolutely. When I say we don't adhere to the spec is not out of lacking knowledge or will, it's a conscious decision. My impression is that the scxml spec (assuming that's the spec we're talking about 😝) is too broad - in certain cases (like actions), it gives many ways to achieve the same thing. And that's totally fine, nothing wrong with this concept, but it does imply that a library that fully follows the spec will necessarily have a bigger footprint (and bigger docs, etc).
useStateMachine has a premise of simplicity: I like that almost all documentation fits in a README.

You might also want to check what Steve does with state-designer

Oh, I've been following state-designer, Robot, react-states and others for a long time. My initial experience with State Machines was with Ruby on Rails back in the day, and I also had a fairly popular state machine library for ActionScript many years ago.

What happens when the machine in node a.b.c takes an event X?

What you described is correct. The library will check if the current state node or any of its parents listens to the "X" event (meaning they have "X" in their "ON" definition).
If they do, it will exit all nested states up until a common ancestor (if any), then enter each nested state until the target.

Let's say we have this:

{
  initial: 'A',
  states: {
    A: {
      initial: 'A1',
      states: {
        A1: {
          initial: 'A12',
          states: {
            A12: {  
            },
          },
          on: {
            GOTOA2: 'A2',
          },
        },
        A2: {},
      },
    },
    B: {},
  },
}

Initial state is A.A1.A12

If I send ("GOTOA2), it will check recursively if the states take this event: A12? No. A1? Yes. So the transition will happen.

Exit A12
Exit A1
(A is the common ancestor, so it does not exit nor enter)
Enter A2

@cassiozen
Copy link
Owner

If the behavior going to be same as this, well then it might take some considerable time and effort. Especially writing the tests, implementation might be written quicker.

No problem, we can do this on a later release.

@devanshj
Copy link
Contributor Author

Yeah my impression of the spec is similar and I never meant following the spec exactly - what I meant was let's not do something too out of the way and we aren't as you said the transition behavior is going to be same. And anyways you're more expert at this than me so no worries about that :P

Cool then we'll keep this for a later release, so this will be my next task after I'm done with lite-types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants