Search code examples
typescriptreduxtypescript-typingsmapped-types

Typescirpt mapped typed in redux reducer factory typings problem


I'm trying to achieve type-safe function to create reducers based on "action handlers" map. The idea is to have API that will look like this:

export const Actions = {
    setToken: (token: string) => createAction(SET_TOKEN_TYPE, token),
    invalidateToken: () => createAction(INVALIDATE_TOKEN_TYPE),
    startLogin: () => createAction(START_LOGIN_TYPE)
};

export const reducer = createReducer<State, typeof Actions>(
    {
        [SET_TOKEN_TYPE]: ({ loginError, ...state }, action) => ({
            ...state,
            token: action.payload,
            loading: false
        }),
        [INVALIDATE_TOKEN_TYPE]: ({ token, ...state }) => state,
        [START_LOGIN_TYPE]: ({ loginError, ...state }) => ({
            ...state,
            loading: true
        })
    },
    {
        loading: false
    }
);

createReducer function should (without Typescript for clarity) look then like this:

function createReducer(handlers, initialState) {
    return (state = initialState, action) => {
        if (action.type in handlers) {
            return handlers[action.type](state, action);
        }
        return state;
    };
}

I created such typed function to have type-safety:

interface Action<T extends string> {
    type: T;
}
type ActionCreator<T extends string> = (...args: any) => Action<T>;
type ActionsCreators = {
    [creator: string]: ActionCreator<any>;
};
type ActionsUnion<Actions extends ActionsCreators> = ReturnType<
    Actions[keyof Actions]
>;
type ActionHandlers<ActionCreators extends ActionsCreators, State> = {
    [K in ReturnType<ActionCreators[keyof ActionCreators]>["type"]]: (
        state: State,
        action: ReturnType<ActionCreators[K]> 
    ) => State
};

    function createReducer<State, Actions extends ActionsCreators>(
    handlers: ActionHandlers<Actions, State>,
    initialState: State
) {
    return (
        state: State = initialState,
        action: ActionsUnion<Actions>
    ): State => {
        if (action.type in handlers) {
            // unfortunately action.type is here any :(
            return handlers[action.type](state, action); // here I have the error
        }
        return state;
    };
}

In the handlers[action.type] I have error (with noImplicitAny: true)

Element implicitly has an 'any' type because type 'ActionHandlers' has no index signature.

Any idea how to have typed action.type inside reducer?

You can find the whole example in the gist


Solution

  • The reason you get the error is action.type is typed as any implicitly since there was no applicable type applied.

    At some point in the chain you use any as a type parameter for something that needs to be rooted in string:

    type ActionsCreators = {
        [creator: string]: ActionCreator<any>;
    };
    

    If you add a type parameter here you can replace the any; however, you will need to pass it all the way down the line.

    See the below version where this update has been made. I had to rename some of the intermediate types to generic names (T or P) since I was having a hard time keeping the typings straight.

    With the extra type parameter <P>, we now have a type for the following instead of implicit any:

    const f = handlers[action.type];
    

    here, f becomes ActionHandlers<P, T, State>[P]

    export interface Action<T extends string> {
        type: T;
    }
    
    export interface ActionWithPayload<T extends string, P> extends Action<T> {
        payload: P;
    }
    
    export type ActionCreator<T extends string> = (...args: any) => Action<T>;
    
    export type ActionsCreators<T extends string> = {
        [creator: string]: ActionCreator<T>;
    };
    
    export type ActionsUnion<P extends string, T extends ActionsCreators<P>> = ReturnType<T[keyof T]>;
    
    export type ActionHandlers<P extends string, T extends ActionsCreators<P>, State> = {
        [K in ReturnType<T[keyof T]>["type"]]: (
            state: State,
            action: ReturnType<T[K]>
        ) => State
    };
    
    export function createReducer<P extends string, State, T extends ActionsCreators<P>>(
        handlers: ActionHandlers<P, T, State>,
        initialState: State
    ) {
        return (state: State = initialState, action: ActionsUnion<P, T>): State => {
    
            if (action.type in handlers) {
                const f = handlers[action.type];
                return f(state, action);
            }
    
            return state;
        };
    }