Home > Net >  How to derive Action type from mapping object for useReducer dispatch type safety?
How to derive Action type from mapping object for useReducer dispatch type safety?

Time:12-23

Preamble

Typically, I'll define a reducer like below so that I have type safety with the dispatch method:

const setWhatever = (state: State, payload: Partial<State>) => ({ ...state, ...payload });

// Maps an action type to an action handler
const actions = {
  SET_WHATEVER: setWhatever,
}

type Action =
  | { type: 'SET_WHATEVER', payload: Parameters<typeof setWhatever>[1] }

const reducer = (state: State, action: Action) => actions[action.type](state, action.payload);

const [state, dispatch] = useReducer(reducer, {});

The repetitive nature of the type Action is slightly annoying. Is there any way for me to derive the type Action based off of the const actions?

Current v1 Solution

I actually have a mostly-working example of a CreateActions<T> Mapped Type. Here's the CodeSandbox (v1) example.

type CreateActions<T extends typeof actions> = {
  [K in keyof T]: {
    type: K;
    payload: T[K] extends (...args: any[]) => any ? Parameters<T[K]>[1] : never;
  };
}[keyof T];

type Action = CreateActions<typeof actions>;

But there are a couple more things that don't make this perfect.

  1. The reducer needs an extra as never casting to make some linting error messages go away:
/**
 * The `as never` below "fixes" the following typing error message:
 * ```
 * Argument of type 'string | number | Partial<{ name: string; age: number; }> | undefined' is not assignable to parameter of type 'never'.
 *  Type 'undefined' is not assignable to type 'never'.ts(2345)
 * ```
 * 
 * QUESTION: is there a better way to fix the error message?
 */
const reducer = (state: State, action: Action): State =>
  actions[action.type](state, action.payload as never);
  1. I'm forced to have a payload in the dispatch. Anyway I can make some actions not require a payload?
dispatch({ type: 'RESET', payload: undefined }); // linter complains with dispatch({ type: 'RESET' });

Current v2 Solution

Updated CodeSandbox (v2) example with the final solution thanks to @ryskajakub.

type Payload<T> = T extends (...args: any[]) => any
  ? Parameters<T>[1]
  : never;

type CreateActions<T extends typeof actions> = {
  [K in keyof T]: Payload<T[K]> extends undefined
    ? {
        type: K;
        // defined so reducer doesn't complain about missing action.payload
        payload?: undefined;
      }
    : {
        type: K;
        payload: Payload<T[K]>;
      };
}[keyof T];

/** Hover over Action to see the discriminated union type. */
type Action = CreateActions<typeof actions>;

const reducer: ActionHandler<Action> = (state, action): State =>
  (actions[action.type] as ActionHandler<typeof action["payload"]>)(
    state,
    action.payload
  );

So now I can call dispatch({ type: 'RESET' }) without any typing error.

There is a "gotcha" in that I can also call dispatch({ type: 'RESET', payload: undefined }) without any typing error, but I don't care about that.

CodePudding user response:

As for the first question, you can also make a conditional type based on the type of the payload. If payload is such that you want to ignore it, you can create a type where the payload won't be a part of object.

type Payload<T> =  T extends (...args: any[]) => any ? Parameters<T>[1] : never

type CreateActions<T extends typeof actions> = {
  [K in keyof T]: Payload<T[K]> extends undefined ? {
    type: K
  } : {
    type: K;
    payload: Payload<T[K]>;
  };
}[keyof T];

It would be best if you would put the second question in separate question.

  • Related