Hello,
I'm trying to understand how to correctly type a reducer with redux-starter-kit.
By dabbling, I came up with the following result.
I don't think it's perfect, but it's open to discussion.
import { createAction, createReducer, PayloadAction } from 'redux-starter-kit';
export type Language = 'en' | 'fr';
export type Theme = 'original' | 'remix';
// State
interface ILayoutState {
language: Language;
theme: Theme;
}
const initialState: ILayoutState = {
language: 'en',
theme: 'remix'
};
// Action constants
const SET_LANGUAGE = '@layout/SET_LANGUAGE';
const SET_THEME = '@layout/SET_THEME';
// Action types
type SetLanguageAction = PayloadAction<Language, typeof SET_LANGUAGE>;
type SetThemeAction = PayloadAction<Theme, typeof SET_THEME>;
type LayoutAction = SetLanguageAction | SetThemeAction;
// Actions
const setLanguage = createAction<Language, typeof SET_LANGUAGE>(SET_LANGUAGE);
const setTheme = createAction<Theme, typeof SET_THEME>(SET_THEME);
// Reducers
const layoutReducer = createReducer<ILayoutState, LayoutAction>(initialState, {
[setLanguage.type]: (state, action) => {
state.language = (action as SetLanguageAction).payload;
},
[setTheme.type]: (state, action) => {
state.theme = (action as SetThemeAction).payload;
}
});
export default layoutReducer;
Why not use createSlice?
@phmatray,
The default typings in this project aren't really ideal. You end up with just as much boilerplate as vanilla redux, and you lose type information so you have to cast. Even with casting, there's no compiler verification that you actually reduce the right actions.
I retyped createAction and createReducer for a project, so I thought I'd share in case you or anyone else finds it helpful.
import {
Action as _Action,
createAction as _createAction,
createReducer as _createReducer,
PayloadAction,
PayloadActionCreator
} from "redux-starter-kit";
type UnionToIntersection<U> = (U extends unknown
? (k: U) => void
: never) extends ((k: infer I) => void)
? I
: never;
export type SameType<T1, T2> = T1 extends T2
? T2 extends T1
? true
: false
: false;
type Filter<TUnion, TType> = TUnion extends TType ? TUnion : never;
export type AllOfType<TUnion, TType> = SameType<
[TUnion],
[Filter<TUnion, TType>]
>;
type Action<TType, TPayload> = TType extends string
? (TPayload extends void ? _Action<TType> : PayloadAction<TPayload, TType>)
: never;
type Reducer<TState, TCreator> = TCreator extends PayloadActionCreator<
infer TPayload,
infer TType
>
? {
[k in TCreator["type"]]: (
state: TState,
action: Action<TType, TPayload>
) => TState | undefined | void
}
: never;
export type Reducers<TState, TCreator> = AllOfType<
TCreator,
PayloadActionCreator
> extends true
? UnionToIntersection<Reducer<TState, TCreator>>
: never;
// tslint:disable-next-line: no-unnecessary-callback-wrapper
export const createAction = <P = unknown>() => <T extends string = string>(
t: T
) => _createAction<P, T>(t);
export const createReducer = <TState, TCreator>(
initial: TState,
reducers: Reducers<TState, TCreator>
) => {
// tslint:disable-next-line: no-any
return _createReducer(initial, reducers as any);
};
With this, your code can be simplified to:
import { createAction, createReducer } from "./redux-retyped";
export type Language = "en" | "fr";
export type Theme = "original" | "remix";
// State
interface ILayoutState {
language: Language;
theme: Theme;
}
const initialState: ILayoutState = {
language: "en",
theme: "remix"
};
// Actions
const setLanguage = createAction<Language>()("@layout/SET_LANGUAGE");
const setTheme = createAction<Theme>()("@layout/SET_THEME");
type LayoutAction = typeof setLanguage | typeof setTheme;
// Reducers
const layoutReducer = createReducer<ILayoutState, LayoutAction>(initialState, {
"@layout/SET_LANGUAGE": (state, action) => {
state.language = action.payload;
},
"@layout/SET_THEME": (state, action) => {
state.theme = action.payload;
}
});
export default layoutReducer;
To the best of my knowledge (which is probably wrong), everything is statically typed -- you can't forget, misspell, or add extra reducers. In VS Code, the reducer names are even auto-suggested.
You can play around with this in a basic sandbox at redux-starter-kit-typescript
@orokanasaru : If you think the types can be improved, please file a PR. I don't pretend to have enough TS knowledge to know the difference, but there's some other folks who would.
@phmatray
I created my own toolkit for React, Redux, and Typescript and then I realized it's very similar to this project 馃槈
@markerikson
It's hard to improve types because many functions require extra annotations and more boilerplate. I noticed that many projects that are written in JS have similar problems. Usually, API must be rewritten to make it typescript friendly.
@markerikson, I'll try to take a look at seeing if I can get a PR for changes in the near future. I'm not sure how much I can do without touching the JS interface. The bit I wrote above abuses currying with empty parameter lists to work around the lack of partial type inference in TS, but that would make no sense to consumers from other languages.
Is this working any better for you in v0.5.0?
Using createSlice has been a very smooth process for me. From what I gather, it is the main method of reducing boilerplate in the project. My implementation of the original example would look like this:
import { createSlice, PayloadAction } from 'redux-starter-kit';
export type Language = 'en' | 'fr';
export type Theme = 'original' | 'remix';
interface ILayoutState {
language: Language;
theme: Theme;
}
const initialState: ILayoutState = {
language: 'en',
theme: 'remix'
};
export const { actions, reducer } = createSlice({
slice: 'layout',
initialState,
reducers: {
setLanguage(state: ILayoutState, { payload }: PayloadAction<Language>) {
state.language = payload;
},
setTheme(state: ILayoutState, { payload }: PayloadAction<Theme>) {
state.theme = payload;
}
}
});
export default reducer;
After this, you'll have actions.setLanguage(), which takes a Language, and actions.setTheme(), which takes a Theme. The type property on the PayloadAction will be automatically generated at run-time by concatenating the slice option fed to createSlice with the names of each respective reducer, but you don't need it for compile-time checking because PayloadAction, the action creators, and the reducers are all fully typed based on the payload itself.
Closing, as I don't think there's anything actionable here. As previously said, I'm happy to accept any PRs that actually improve the typings.
Most helpful comment
Using
createSlicehas been a very smooth process for me. From what I gather, it is the main method of reducing boilerplate in the project. My implementation of the original example would look like this:After this, you'll have
actions.setLanguage(), which takes aLanguage, andactions.setTheme(), which takes aTheme. Thetypeproperty on thePayloadActionwill be automatically generated at run-time by concatenating thesliceoption fed tocreateSlicewith the names of each respective reducer, but you don't need it for compile-time checking becausePayloadAction, the action creators, and the reducers are all fully typed based on the payload itself.