1

(Follow up to this question)

I have a function that accepts a configuration object with some state definitions (It's a state machine function).

Example:

const machine = createStateMachine({
  initial: 'inactive',
  states: stateNode({
    inactive: {
      on: { ACTIVATE: 'active' },
    }),
    active: stateNode({
      on: { DEACTIVATE: 'inactive' },
      effect(send) {
        send('DEACTIVATE') // Only accepts "DEACTIVATE".
      },
    }),
  },
});

The user can switch the current state by calling send, which is available in two places:

  • Returned from the effect function within the configuration
  • Returned from the createStateMachine function

I want to infer as much as possible from the configuration, and stateNode is an intermediary function that helps with the type inference:

function stateNode<T extends Record<keyof T, PropertyKey>>({on, effect}: {
  on: T, effect?: (send: (action: keyof T) => void) => void}) {
  return { on, ...effect && { effect } }
}

So far everything works, but I wanted to make the "on" property optional - any ideas?

Here's a link to a TS Playground with the full code

7
  • I don't understand... if you leave out on, then you have to leave out effect also, right? Because the send() callback would take never. But then your state node is just {}, so you don't have to call stateNode() explicitly. Sort of like this. Does that work how you want it to? Or am I missing something. Commented May 6, 2021 at 1:34
  • Fair question, I should have explained it better: An effect can be used to run arbitrary code when the state machine enters or leaves that state. In states that contain transitions events, you can also send an event, but that's not all that can be done within an effect. So, from that original example, I could have frozen: stateNode({ effect(){ console.log("Entered Frozen") } } }), Commented May 6, 2021 at 3:36
  • So then, something like this? If so I'll try to write up the answer (although I see I didn't get around to the previous question yet) Commented May 6, 2021 at 13:26
  • I'm ready to accept your answer on the first one, and this one is also perfect! I was trying to use multiple function signature definitions, but couldn't get quite there. Thanks a lot for all your help, if there's a way, I'd love to buy you a coffee or a beer ;) Commented May 6, 2021 at 14:09
  • The only thing is that now I'm getting never as the param for the function returned by createStateMachine Commented May 6, 2021 at 14:12

1 Answer 1

1

Conceptually, all you'd need to do is make on optional everywhere it's currently required, and use the NonNullable<T> utility type everywhere you are programmatically using the type of on, and things would just work:

type States<T extends Record<keyof T, { on?: any }>> = { [K in keyof T]: {
  on?: Record<keyof (NonNullable<T[K]['on']>), keyof T>,
  effect?(send: (action: keyof NonNullable<T[K]['on']>) => void): void;
} }

function createStateMachine<
  T extends States<T>>(states: {
    initial: keyof T,
    states: T
  }) {
  return function send(arg: KeysOfTransition<NonNullable<T[keyof T]['on']>>) {
  };
}

function stateNode<T extends Record<keyof T, PropertyKey>>({ on, effect }: {
  on?: T, effect?: (send: (action: keyof T) => void) => void
}) {
  return { ...on && { on }, ...effect && { effect } }
}

Unfortunately that doesn't quite work out:

const a = createStateMachine({
  initial: 'inactive',
  states: {
    inactive: stateNode({
      on: {
        ACTIVATE: 'active'
      }
    }),
    frozen: stateNode({ effect() { console.log("Entered Frozen") } }), // error!
//  ~~~~~~
// Type 'Record<string | number | symbol, string | number | symbol>' is not assignable to 
// type 'Record<string | number | symbol, "inactive" | "active" | "frozen">'.
    active: stateNode({
      on: {
        DEACTIVATE: 'inactive',
      },
      effect: send => {
        send('DEACTIVATE')
      },
    })
  },
});
// const a: (arg: string | number | symbol) => void

The compiler correctly infers T when you call stateNode with something that has an on property, but when you leave it out, the compiler infers Record<PropertyKey, PropertyKey>, and that's not what you want at all. Even if I could make that compile, PropertyKey would make the compiler forget all the names of your states, and you'd end up with something useless like (arg: PropertyKey) => void coming out of createStateMachine(). You'd like T to be undefined or {} or Record<never, any> or something. Well, I tried playing around with a single call signature for stateNode, but contextual typing kept causing T to be inferred with this unfortunately wide type.


Instead, I'd suggest making stateNode() an overloaded function so that we have more control over what happens when you leave out the on property:

function stateNode<T extends Record<keyof T, PropertyKey>>(param: {
  on: T;
  effect?: ((send: (action: keyof T) => void) => void) | undefined;
}): typeof param;
function stateNode(param: { effect?: () => void, on?: undefined }): typeof param;
function stateNode<T extends Record<keyof T, PropertyKey>>({ on, effect }: {
  on?: T, effect?: (send: (action: keyof T) => void) => void
}) {
  return { ...on && { on }, ...effect && { effect } }
}

If you specify on when you call stateNode(), the first call signature is used and the behavior is the same as before. If you leave on out, the second call signature is used. Here there is no T type to infer at all; instead, the compiler sees effect as an optional no-arg function (because, as you said in your comments, this is what you want). In both cases the output is the same type as the input. Let's see if this improves things:

const a = createStateMachine({
  initial: 'inactive',
  states: {
    inactive: stateNode({
      on: {
        ACTIVATE: 'active'
      }
    }),
    frozen: stateNode({ effect() { console.log("Entered Frozen") } }),
    active: stateNode({
      on: {
        DEACTIVATE: 'inactive',
      },
      effect: send => {
        send('DEACTIVATE')
      },
    })
  },
});
// const a: (arg: "ACTIVATE" | "DEACTIVATE") => void

Looks good!

Playground link to code

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.