I try to create typings for events that will be processed in xstate.
Currently xstate calls the processChangeEvent
function with a DefinedEvent
parameter. This typing can't be changed.
The processChangeEvent
should always be called with a change event therefore I would like to achieve a way to check if the given event is a change event or else throw an error.
Everything works inside an if statement. The typescript compilers realizes the CHANGE
event type and therefor I can access the value
attribute.
export type Event<TType, TData = unknown> = { type: TType } & TData;
export type Value<TType> = { value: TType };
export type DefinedEvents = Event<'INC'> | Event<'DEC'> | Event<'CHANGE', Value<number>>;
function processChangeEventOK(event: DefinedEvents) {
if (event.type === 'CHANGE') {
return event.value; // NO COMPILER ERROR :-)
} else {
throw new Error(`Event must be of type CHANGE but is ${event.type}`);
}
}
The problem is that I need this a lot of times. Therefore I tried to extract the logic inside the processEventOrThrowException
function. I know that somehow the callback must be differently typed Event<T
but I wouldn't know how?
function processChangeEvent(event: DefinedEvents) {
return processEventOrThrowException(event, 'CHANGE', (castedEvent) => {
return castedEvent.value; // COMPILER ERROR :-(
});
}
function processEventOrThrowException<TType>(event: Event<any>, type: string, callback: (castedEvent: Event<TType, unknown>) => any) {
if (event.type === type) {
return callback(event);
} else {
throw new Error(`Event must be of type CHANGE but is ${event.type}`);
}
}
Let's define the following helper types and user-defined type guard function:
type EventTypes = DefinedEvents['type'];
type EventOfType<T extends EventTypes> = Extract<DefinedEvents, { type: T }>
function isEventOfType<T extends EventTypes>(
event: DefinedEvents,
type: T
): event is EventOfType<T> {
return event.type === type;
}
This takes your DefinedEvents
discriminated union and abstracts the operation of checking the type
property to distinguish between members of the union, using the Extract
utility type. Armed with these, I'd define processEventOrThrowException()
like this:
function processEventOrThrowException<T extends EventTypes, R>(
event: DefinedEvents,
type: T,
callback: (castedEvent: EventOfType<T>) => R
) {
if (isEventOfType(event, type)) {
return callback(event);
} else {
throw new Error(`Event must be of type ${type} but is ${event.type}`);
}
}
That's generic in both the type T
of the type
argument, and the type R
of the callback return value. Now your processChangeEvent()
should work as expected:
function processChangeEvent(event: DefinedEvents) {
return processEventOrThrowException(event, 'CHANGE', (castedEvent) => {
return castedEvent.value
});
}
and because of the R
type in processEventOrThrowException()
, the return type of processChangeEvent()
is inferred to be number
:
const val = processChangeEvent({ type: "CHANGE", value: Math.PI });
console.log(val.toFixed(2)) // 3.14
processChangeEvent({ type: "DEC" }); // Error: Event must be of type CHANGE but is DEC
Looks good.