Bug: Rejected promises do not provide typed data
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
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.
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!
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
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
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!
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 }
}
}
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 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.