Extract correct union type at runtime inside extraced function

127 views Asked by At

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<Tbut 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}`);
    }
}
2

There are 2 answers

4
jcalz On BEST ANSWER

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.

Playground link to code

0
Guilhermevrs On

Link for the playground

export type PossibleTypes = 'INC' | 'DEC' | 'CHANGE';

export type Event < TType extends PossibleTypes, TData = unknown > = {
  type: TType
} & TData;
export type Value < TType > = {
  value: TType
};

export type DefinedEvents < T extends PossibleTypes = PossibleTypes > =
  T extends 'CHANGE' ?
  Event < 'CHANGE', Value < Number >>
  : Event < T >

  function processChangeEvent(event: DefinedEvents) {
    return processEventOrThrowException(event, 'CHANGE', (castedEvent) => {
      return castedEvent.value; // COMPILER ERROR :-(
    });
  }

function processEventOrThrowException < T extends PossibleTypes > (event: DefinedEvents, type: T, callback: (castedEvent: DefinedEvents < T > ) => any) {
  const isCorrectEvent = (e: DefinedEvents): e is DefinedEvents < T > => event.type === type
  if (isCorrectEvent(event)) {
    return callback(event);
  } else {
    throw new Error(`Event must be of type CHANGE but is ${event.type}`);
  }
}

Explanation

  • List all the possible types
  • Use the conditional typing to define the DefinedEvents
  • Use a type predicate to identify if event is of the correct type