typescriptreduxtypescript-typingsredux-toolkit

How can I get a typesafe POJO of actions mapped to a string from a Slice (redux toolkit)


I'm trying to setup a function that returns a typesafe POJO of a Slice's action names mapped to the corresponding action type name (using the convention of sliceName/sliceAction). The function is incredibly straightforward but getting TypeScript to recognize the keys has embarrassingly tripped me up.

I'm following along with the example from Redux Toolkit's tutorial but I wanted to see if this were possible. Ideally, I'll be able to use the function in conjunction with either Redux-Saga or Redux-Observable to avoid using string literals as action types.

I've setup a codesandbox with my attempt.

const getActions = <T extends Slice>(slice: T) => {
  const keys = Object.keys(slice.actions) as Array<keyof typeof slice.actions>;
  return keys.reduce(
    (accumulator, key) => {
      accumulator[key] = `${slice.name}/${key}`;
      return accumulator;
    },
    {} as Record<keyof typeof slice.actions, string>
  );
};

The intent is to be able to take a slice like this:

const issuesDisplaySlice = createSlice({
  name: "issuesDisplay",
  initialState,
  reducers: {
    displayRepo(state, action: PayloadAction<CurrentRepo>) {
      //...
    },
    setCurrentPage(state, action: PayloadAction<number>) {
      //...
    }
  }
});

and get a TypeScript to recognize the output to be:

{
  displayRepo: string,
  setCurrentPage: string
}

Thanks for taking the time to read this and I especially appreciate anyone who takes the time to try and solve it. I know your time is valuable :)


Solution

  • Looking at your codesandbox code, it seems that keyof typeof slice.actions is not sufficiently generic to express what you're trying to do. Even though slice is a of generic type T extending Slice, when you evaluate slice.actions, the compiler treats it as if it were just Slice:

    const actions = slice.actions; // CaseReducerActions<SliceCaseReducers<any>>
    

    And that's unfortunate because keyof CaseReducerActions<SliceCaseReducers<any>> (aka keyof Slice['actions']) is just string | number and not the specific keys you've passed in on T:

    type KeyofSliceActions = keyof Slice['actions'];
    // type KeyofSliceActions = string | number
    

    Widening generic values to their constraints upon property lookup is probably good for performance, but leads to some undesirable situations like this one. See microsoft/TypeScript#33181 for the relevant issue.

    Ideally, when you look up the actions property on a value of type T, the compiler would treat the result as the lookup type T['actions'] instead. This doesn't happen automatically, but you can ask the compiler to do it with a type annotation:

    const genericActions: T['actions'] = slice.actions;  // this is acceptable
    

    Now, if you replace instances of slice.actions in the rest of your code with genericActions, the typings will work the way you want them:

    const getActions = <T extends Slice>(slice: T) => {
      const genericActions: T['actions'] = slice.actions;  // this is acceptable
      const keys = Object.keys(genericActions) as Array<keyof typeof genericActions>;
      return keys.reduce(
        (accumulator, key) => {
          accumulator[key] = `${slice.name}/${key}`;
          return accumulator;
        },
        {} as Record<keyof typeof genericActions, string>
      );
    };
    
    // const getActions: <T extends Slice<any, SliceCaseReducers<any>, string>>(
    //   slice: T) => Record<keyof T["actions"], string>
    

    (Aside: keyof typeof genericActions can be shortened to keyof T['actions'].) You can see that getActions() now returns Record<keyof T["actions"], string>, a type that depends on T.

    Let's see if it works:

    const actions = getActions<typeof issuesDisplaySlice>(issuesDisplaySlice);
    // const actions: Record<"displayRepo" | "setCurrentPage" | "setCurrentDisplayType", string>
    
    actions.displayRepo.toUpperCase(); // okay
    actions.displayTypo.toUpperCase(); // error!
    // ---> ~~~~~~~~~~~
    // Property 'displayTypo' does not exist on type 
    // 'Record<"displayRepo" | "setCurrentPage" | "setCurrentDisplayType", string>'.
    // Did you mean 'displayRepo'?
    

    Looks good to me.

    Playground link to code