xstate icon indicating copy to clipboard operation
xstate copied to clipboard

Bug: Rejected promises do not provide typed data

Open josephfh opened this issue 3 years ago • 8 comments

Description

Reporting this issue at the request of @davidkpiano https://discord.com/channels/795785288994652170/942027749646823434/942055643232223253

Problem: there are no types on the event on action 'assignErrorsToContext' from the rejected Promise in the service


interface Errors {
  [key: string]: string
}

export const formStateMachine = createMachine(
  {
    id: 'formState',
    tsTypes: {} as import('./form-state.machine.typegen').Typegen0,
    schema: {
      context: {} as {
        errors: Errors
      },
      services: {} as {
        validator: {
          data: {}
        }
      },
    },
    context: {
      errors: {},
    },
    initial: 'validating',
    states: {
      validating: {
        id: 'validating',
        invoke: {
          src: 'validator',
          onDone: {
            target: 'resolved',
          },
          onError: {
            actions: 'assignErrorsToContext',
            target: 'rejected',
          },
        },
      },
      resolved: {},
      rejected: {},
    },
  },
  {
    actions: {
      assignErrorsToContext: assign<any, any>((_, event) => {
        if (event.type !== 'error.platform.validating:invocation[0]') {
          return {}
        }
        return {
          errors: event.data.errors,
        }
      }),
    },
    services: {
      validator: async (): Promise<true> => {
        const errors: Errors = {}
        errors.mainframe = 'Someone is hacking the mainframe'
        errors.shields = 'Shields at 25%'
        errors.other = 'I have lost my keys AGAIN'
        return Object.values(errors).find((i) => i) ? Promise.reject({ errors }) : Promise.resolve(true)
      },
    },
  }
)

Expected result

I expect types, or the ability to manually specify the types on the action <the context from the schema, rejected error data>

Actual result

Typescript error on event.data.whatever Object is of type 'unknown'

Reproduction

https://stately.ai/viz/15414db7-87d6-4c55-92b0-ffae6b141a47

Additional context

No response

josephfh avatar Feb 12 '22 15:02 josephfh

This is basically how TypeScript/JavaScript works. There is no good way to type errors because just anything can be thrown and the compiler can't know what errors can be thrown from all of the functions in the stack/chain. The Promise type in TS also doesn't have a generic that would represent an error data type.

Andarist avatar Feb 12 '22 15:02 Andarist

Is it possible to manually define the expected error event, without resorting to <any, any> ?

I can't work out how to get the context as it's defined in the same machine

If not, is there a good alternative to get data to an onError event?

Thanks!

josephfh avatar Feb 12 '22 18:02 josephfh

As a workaround for now I would propose resolving with the error data and handle this within onDone.

I would also appreciate if you could share a repro case of what you are trying to achieve here - just to make sure that we are on the same page

Andarist avatar Feb 12 '22 18:02 Andarist

I want to create services that can have different types of responses when the are successful and when they aren't. I need to pass back multiple errors when validation a large form. Using the onDone and onError routes from an invoked service looks much explicit in the state machine than conditions on the onDone to handle errors.

There may be a better way to achieve this than what i have now. Maybe this isn't a bug and more a modelling design preference!

Thanks for looking

josephfh avatar Feb 12 '22 22:02 josephfh

Hopefully this helps someone that has this issue too. I think the "new" way, if I am correct, could be documented better.

"Old" way

If you are providing your context and events types as generics to createMachine, you can add success/error types in the event typings by using 'done.invoke.serviceName' and 'platform.error.serviceName'.

type DogEvent =
  | { type: 'SUBMIT' }
  | {
      type: 'done.invoke.fetchSpecialDog';
      data: Dog;
    }
  | { type: 'error.platform.fetchSpecialDog'; data: FetchErrors };

// defining machine
export const dogMachine = createMachine<DogContext, DogEvent>(...);

fetchSpecialDog: async (ctx) => {
  const response = await fetchDog({ query: ctx.searchQuery });

  if (!response.errors) return Promise.resolve(response.data);
  return Promise.reject(response.errors);
},

"New" way

If you are using typegen it is a bit different - there isn't as much control of the errors, but they can be cast later.

// for schema
type DogService = {
  fetchDogs: { data: Dog[] }; 
  fetchSpecialDog: { data: Dog }; // only type success format
};

// defining machine
export const dogMachine = createMachine({
  // ...

  schema: {
    context: {} as DogContext,
    events: {} as DogEvent,
    services: {} as DogService,
  },
  tsTypes: {} as import('./dog.machine.typegen').Typegen0,

  // ...
});

// service
fetchSpecialDog: async (ctx) => {
  const response = await fetchDog({ query: ctx.searchQuery });

  if (!response.errors) return Promise.resolve(response.data); 
  throw response.errors; // don't use Promise.reject or it will confuse TS
},

// in actions from the onError path
// `event.data` will be undefined
assignFetchErrors: assign((_ctx, event) => {
  const fetchErrors = event.data as FetchErrors; // cast to your desired type
  return { fetchErrors };
}),

In many cases you don't technically need Promise.resolve (you can just return successValue;), but if you have no await statements TS complains. So if your service returns void or errors, you'll just end the method with return Promise.resolve();. EDIT: You will need the method declared as async or else throwing an error will not be caught by onError properly!

parker-codes avatar Jul 09 '22 17:07 parker-codes

I have a proposal but I'm not sure if it will work. Currently the success invoke event type is something like:

ActionObject<Context, {
    type: "done.invoke.sessionState.joining:invocation[0]";
} & {
    data: SessionJoinResource;
}>

And the {data: SessionJoinResource} is what we specified in services schema.

So maybe we can add a serviceError schema to specified error event types, like:

{
  serviceError: {
    myService: { data: MyErrorType }
  }
}

Jokcy avatar Aug 23 '22 03:08 Jokcy

The question is how do we make things like this an error? rejects/throws are not typeable in TypeScript and thus we don't have actual control over what we could receive in onError because it could be just anything:

createMachine({
  schema: {
    serviceError: {
      myService: { data: MyErrorType }
    }
  }
}, {
  services: {
    myService: () => Promise.reject(new Error('oops'))
  }
})

Andarist avatar Aug 23 '22 06:08 Andarist

@Andarist I totally understand the sentiment, but it worked before with the "old" API. 😄

{ type: 'error.platform.fetchSpecialDog'; data: FetchErrors } in the events list properly typed the expected data in the onError path.

parker-codes avatar Aug 24 '22 14:08 parker-codes