typescriptstate-machinexstate

XState typed Actions conflicting with Event types


I'm using XState(5.18.2) with TS. Some event types require extra parameters, some don't. Actions use the event types, but don't seem to differentiate which events are used by which action.

Simple reproduction here:

import { setup } from 'xstate'

const exampleMachine = setup({
  types: {
    events: {} as {
      | { type: 'eventWithNoParams' }
      | { type: 'eventWithParams', meta: string }
    }
  },
  actions: {
    doSomething({context, event}, params) {
      event.meta // typeError: Property 'meta' does not exist on type { type: 'eventWithNoParams' } | { type: 'eventWithParams', meta: string }
      params // is type unknown
    }
  }
}).createMachine({
  id: 'example',
  initial: 'start',
  states: {
    start: {
      on: {
        'eventWithNoParams': 'nextState'
      }
    },
    nextState: {
      on: {
        'eventWithParams': 'nextState'
        actions: 'doSomething'
      }
    },
    finalState: {
      type: 'final'
    }
  }
})

const actor = createActor(exampleMachine)
actor.start()
actor.send({type: 'eventWithNoParams'}) // state -> nextState
actor.send({type: 'eventWithParams', meta: 'someData' }) // state -> finalState

The code is working fine, and the actions are operating correctly, but I'm getting errors in the action definitions and get zero type safety. I don't know how to ensure only certain event types are used by certain actions. I can't find any examples in the documentation with typed examples of both multiple events with extra parameters as well as multiple actions.

Relevant links:

A similar question was asked here: XState: Types of property 'actions' are incompatible but is in v4 which has a completely different type system and syntax.


Solution

  • After a bit more reading and some help from the discord I've figured it out. The correct syntax is:

    import { setup } from 'xstate'
    
    const exampleMachine = setup({
      types: {
        events: {} as {
          | { type: 'eventWithNoParams' }
          | { type: 'eventWithParams', meta: string }
        }
      },
      actions: {
        doSomething({context, event}, params: { meta: string }) {
          params.meta // simply retype param
        }
      }
    }).createMachine({
      id: 'example',
      initial: 'start',
      states: {
        start: {
          on: {
            'eventWithNoParams': 'nextState'
          }
        },
        nextState: {
          on: {
            'eventWithParams': 'nextState'
            actions: {
              type: 'doSomething'
              params: ({ event }) => ({ meta: event.meta }) // pass param here from event. You'll get type inference in event here from the types up top and type inference from the action param typing.
            }
          }
        },
        finalState: {
          type: 'final'
        }
      }
    })
    
    const actor = createActor(exampleMachine)
    actor.start()
    actor.send({type: 'eventWithNoParams'}) // state -> nextState
    actor.send({type: 'eventWithParams', meta: 'someData' }) // state -> finalState
    

    A little confusing that I had to type it twice but I think it makes sense given any event could potentially trigger any action. For larger payloads the type could be extracted as a type or interface for better reusability.

    ...
    interface Payload { meta: string }
    ...
    types: {
        events: {} as {
          | { type: 'eventWithNoParams' }
          | ({ type: 'eventWithParams' } & Payload)
    ...
        doSomething({context, event}, params: Payload)
    ...