Xstate: TypeScript errors when including a nested state machine from a factory function

Created on 29 Jan 2020  路  12Comments  路  Source: davidkpiano/xstate

Description
I am trying to use a nested state machine from a factory function but TypeScript is does not seem to like that and is returning an error.

This is the factory that constructs the config for the generic FetchMachine.

export function fetchMachineFactory(serviceName: string) {
  return {
    initial: "loading",
    states: {
      loading: {
        invoke: {
          src: serviceName,
          onError: "failed",
          onDone: "done"
        }
      },
      failed: {
        on: {
          RETRY: "loading"
        }
      },
      done: {
        type: "final"
      }
    }
  };
}

It is then embedded in an existing machine like this:

type Context = { id: number };
type Events = { type: "SIGN_IN" } | { type: "RETRY" };

const a = Machine<Context, Events>({
  initial: "signIn",
  states: {
    signIn: {
      invoke: {
        src: "signIn",
        onDone: "fetchTrack",
        onError: "needsSignIn"
      }
    },
    fetchTrack: {
      ...fetchMachineFactory("someService"),
      onDone: "finished"
    },
    needsSignIn: {
      on: {
        SIGN_IN: "signIn"
      }
    },
    finished: {
      type: "final",
      activities: "finished"
    }
  }
});

Expected Result
No TypeScript error

Actual Result
The following TypeScript error:

Type '{ signIn: { invoke: { src: string; onDone: string; onError: string; }; }; fetchTrack: { onDone: string; initial: string; states: { loading: { invoke: { src: string; onError: string; onDone: string; }; }; failed: { ...; }; done: { ...; }; }; }; needsSignIn: { ...; }; finished: { ...; }; }' is not assignable to type 'StatesConfig<Context, any, Events>'.
  Property 'fetchTrack' is incompatible with index signature.
    Type '{ onDone: string; initial: string; states: { loading: { invoke: { src: string; onError: string; onDone: string; }; }; failed: { on: { RETRY: string; }; }; done: { type: string; }; }; }' is not assignable to type 'StateNodeConfig<Context, any, Events>'.
      Types of property 'states' are incompatible.
        Type '{ loading: { invoke: { src: string; onError: string; onDone: string; }; }; failed: { on: { RETRY: string; }; }; done: { type: string; }; }' is not assignable to type 'StatesConfig<Context, any, Events>'.
          Property 'done' is incompatible with index signature.
            Type '{ type: string; }' is not assignable to type 'StateNodeConfig<Context, any, Events>'.
              Types of property 'type' are incompatible.
                Type 'string' is not assignable to type '"final" | "atomic" | "compound" | "parallel" | "history" | undefined'.ts(2322)
types.d.ts(267, 5): The expected type comes from property 'states' which is declared here on type 'MachineConfig<Context, any, Events>'

Reproduction

I prepared a CodeSandBox to reproduce the error: https://codesandbox.io/s/happy-fog-uw3rvd-uw3rv

a-machine.ts is the machine with the TypeScript error

b-machine.ts is the same machine, only that I directly embedded the FetchMachine instead of spreading the result of the factory function.

c-machine.ts uses the factory function but the return type is cast to ...(fetchMachineFactory("someService") as StateNodeConfig<any, any, any>) which seems to work but we were losing types along the way in our project if we used this.

Additional context
XState version: 4.7.6

Do you have a tip on how to better type the return type of the factory function?

documentation typescript

Most helpful comment

This is one of those weird TypeScript things; it's not perfect with inference.

To fix it, add as const to the keyword:

      done: {
-       type: "final"
+       type: "final" as const
      }

All 12 comments

This is one of those weird TypeScript things; it's not perfect with inference.

To fix it, add as const to the keyword:

      done: {
-       type: "final"
+       type: "final" as const
      }

Ohhh, I see!
If xstate would expose an enum that has "final", "atomic", "compound", "parallel" and "history", which we could then use in the client apps, we would not run into the issue then, I guess?

@janmonschke Sure, feel free to open an issue for that feature as an enhancement.

@davidkpiano I just took a stab at it myself. Defined the StateNode.type to be of type enum StateNodeType (...) which theoretically worked. However, in TypeScript, you would now not be allowed to define the StateNode.type as a simple string of e.g. 'final'.
You would be forced to use the enum and I am not sure that is desired as it would make copy-pasting machine configs between JS and TS tedious.
It would also mean that this would become a breaking change for TS users as they'd have to update all their existing machine definitions.

Right, I've tried that before and decided against it because the developer should be able to type type: 'final' without worrying about it.

Perhaps a better solution would be something like...

// tentative API
import { createMachine, createStateNode } from 'xstate';

const red = createStateNode<SomeContext, SomeEvent>({
  initial: 'walk',
  states: {
    // ...
  }
});

// ...

const lightMachine = createMachine({
  // ...
  states: {
    green: {/* ... */},
    yellow: {/* ... */},
    red
  }
});

I see, that would be nice!

Just my 2 cents - it's not that TS is not perfect with inference here. It's that in TS inference is local, really contextual (unlike as in the case of the, for example, Flow). So if you have created a function that returns an object it just doesn't have enough information to infer any better type than a "plain object" of a given structure and because a regular object can be mutated after it has been created TS has to "widen" the type for those strings, because otherwise, this would error:

const a = { foo: 'bar' }
a.foo = 'xyz'

You can also fix your example by annotating the return type of your factory function to StateNodeConfig<any, any, any> (preferably without those anys 馃槈 ).

@Andarist Yep, that is what I did in c-machine.ts in the CodeSandBox, as described above. I added another version d-machine.ts that has the correct types. This was more a question of how TypeScripe would be able to infer those types :)

Ah sorry, I have focused on the broken example and havent noticed that you had alternative solutions there 馃憤

No worries, I'm happy that you had a look and we came to the same conclusion :)

Thanks to both of you for your quick responses!

Could this possibly be related to why the example in https://github.com/davidkpiano/xstate/tree/master/packages/xstate-react#configuring-machines- no longer compiles with TypeScript 3.7.5 (current as of now)? I couldn't resolve the error by adding as const to any strings.

The error is absolutely inscrutable, apologies for deciding to include it:

index.ts:43:3 - error TS2769: No overload matches this call.
  Overload 1 of 2, '(config: MachineConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>, options?: Partial<MachineOptions<{ data: undefined; error: undefined; }, AnyEventObject>> | undefined, initialContext?: { ...; } | undefined): StateMachine<...>', gave the following error.
    Type '{ idle: { on: { FETCH: string; }; }; loading: { invoke: { src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<...>; }; }; }; success: { ...; }; failure: { ...; }; }' is not assignable to type 'StatesConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>'.
      Property 'loading' is incompatible with index signature.
        Type '{ invoke: { src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }; }' is not assignable to type 'StateNodeConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>'.
          Types of property 'invoke' are incompatible.
            Type '{ src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }' is not assignable to type 'StateMachine<any, any, any, any> | { id?: string | undefined; src: string | StateMachine<any, any, any, any> | InvokeCreator<{ data: undefined; error: undefined; }, AnyEventObject, any>; ... 4 more ...; onError?: string | ... 2 more ... | undefined; } | InvokeConfig<...>[] | undefined'.
              Type '{ src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }' is not assignable to type 'undefined'.
  Overload 2 of 2, '(config: MachineConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>, options?: Partial<MachineOptions<{ data: undefined; error: undefined; }, AnyEventObject>> | undefined, initialContext?: { ...; } | undefined): StateMachine<...>', gave the following error.
    Type '{ idle: { on: { FETCH: string; }; }; loading: { invoke: { src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<...>; }; }; }; success: { ...; }; failure: { ...; }; }' is not assignable to type 'StatesConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>'.
      Property 'loading' is incompatible with index signature.
        Type '{ invoke: { src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }; }' is not assignable to type 'StateNodeConfig<{ data: undefined; error: undefined; }, any, AnyEventObject>'.
          Types of property 'invoke' are incompatible.
            Type '{ src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }' is not assignable to type 'StateMachine<any, any, any, any> | { id?: string | undefined; src: string | StateMachine<any, any, any, any> | InvokeCreator<{ data: undefined; error: undefined; }, AnyEventObject, any>; ... 4 more ...; onError?: string | ... 2 more ... | undefined; } | InvokeConfig<...>[] | undefined'.
              Type '{ src: string; onDone: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; onError: { target: string; actions: AssignAction<unknown, DoneInvokeEvent<any>>; }; }' is not assignable to type 'undefined'.

@fasiha Can you put that in a CodeSandbox and open a separate issue for it? Thanks!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

carlbarrdahl picture carlbarrdahl  路  3Comments

doup picture doup  路  3Comments

laurentpierson picture laurentpierson  路  3Comments

amelon picture amelon  路  3Comments

greggman picture greggman  路  3Comments