Chance
Chance

Reputation: 11285

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 :)

Upvotes: 1

Views: 380

Answers (1)

jcalz
jcalz

Reputation: 328578

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

Upvotes: 2

Related Questions