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