reducer.ts

/**
 * Redux reducer tools.
 *
 * @module redux
 * @license Apache-2.0
 * @copyright Mat. 2020-present
 */

/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */

import type { Fun } from "@xcmats/js-toolbox/type";
import { choose, identity } from "@xcmats/js-toolbox/func";

import {
    isACWithPayload,
    isWithPayload,
    type Action,
    type ActionCreator,
    type EmptyActionCreator,
    type PayloadAction,
    type PayloadActionCreator,
    type ReduxCompatAction,
    type ReduxCompatUnknownAction,
} from "../redux/action";




/**
 * redux-compatible Reducer type.
 */
export type ReduxCompatReducer<
    StateType = any,
    ActionShape extends ReduxCompatAction = ReduxCompatUnknownAction,
    PreloadedStateType = StateType,
> = (
    state: StateType | PreloadedStateType | undefined,
    action: ActionShape,
) => StateType;




/**
 * Reducer - function taking state and action and returning a new state.
 */
export type Reducer<
    StateType = any,
    PayloadType = any,
    ActionType extends string = string,
> = (
    state: StateType,
    action: Action<PayloadType, ActionType>,
) => StateType;




/**
 * Create clean and readable reducers for redux.
 *
 * @function createReducer
 * @param initState
 * @returns {ReduxBoundReducer}
 */
export function createReducer<StateType> (initState: StateType): (
    reducers: Record<string, Reducer<StateType>>,
    defaultReducer?: Reducer<StateType>,
) => ReduxCompatReducer<StateType, Action> {
    return (reducers, defaultReducer = identity) =>
        (state = initState, action) =>
            choose(
                action.type,
                reducers,
                defaultReducer,
                [state, action],
            );
}




/**
 * Chainable API for building reducer handling a slice of state.
 */
interface SliceBuildAPI<StateType> {
    // handle empty action (overload)
    handle<ActionType extends string> (
        actionCreator: EmptyActionCreator<ActionType>,
        reducer: (state: Readonly<StateType>) => Readonly<StateType>,
    ): SliceBuildAPI<StateType>;
    // handle action with payload (overload)
    handle<ActionType extends string, PayloadType> (
        actionCreator: PayloadActionCreator<PayloadType, ActionType>,
        reducer: (
            state: Readonly<StateType>,
            payload: PayloadType,
        ) => Readonly<StateType>,
    ): SliceBuildAPI<StateType>;
    // handle unmatched actions
    default (
        reducer: (
            state: Readonly<StateType>,
            action: Action,
        ) => Readonly<StateType>,
    ): SliceBuildAPI<StateType>;
    // match actions using type predicate - useful for matching actions
    // by payload content (overload)
    match<PayloadType> (
        predicate: (action: Action) => action is Action<PayloadType>,
        reducer: (
            state: Readonly<StateType>,
            payload: PayloadType,
        ) => Readonly<StateType>,
    ): SliceBuildAPI<StateType>;
    // match action using boolean predicate - useful for matching actions
    // using string operations on their type (overload)
    match (
        predicate: (action: Action) => boolean,
        reducer: (
            state: Readonly<StateType>,
            payload: never,
        ) => Readonly<StateType>,
    ): SliceBuildAPI<StateType>;
}




/**
 * Statically typed reducer for a slice of state.
 *
 * @function sliceReducer
 * @param initState
 * @returns (builder: (slice: SliceBuildAPI) => void) => ReduxCompatReducer
 */
export function sliceReducer<StateType> (initState: StateType): (
    builder: (slice: SliceBuildAPI<StateType>) => void,
) => ReduxCompatReducer<Readonly<StateType>, Action> {

    const
        reducers = {} as Record<string, Fun>,
        matchers = [] as ReduxCompatReducer<Readonly<StateType>, Action>[];
    let defaultReducer: (
        state: Readonly<StateType>,
        action: Action,
    ) => Readonly<StateType>;

    const
        create = createReducer(initState),
        slice: SliceBuildAPI<StateType> = {
            // handle concrete type of action
            handle: <ActionType extends string, PayloadType>(
                actionCreator: ActionCreator<PayloadType, ActionType>,
                reducer: (
                    state: Readonly<StateType>,
                    payload?: PayloadType,
                ) => Readonly<StateType>,
            ): typeof slice => {
                if (isACWithPayload(actionCreator)) {
                    reducers[actionCreator.type] = (
                        state: Readonly<StateType>,
                        action: PayloadAction<PayloadType, ActionType>,
                    ) => reducer(state, action.payload);
                } else {
                    reducers[actionCreator.type] = (
                        state: Readonly<StateType>,
                    ) => reducer(state);
                }
                return slice;
            },
            // handle unmatched actions (state identity by default)
            default: (reducer) => {
                defaultReducer = reducer;
                return slice;
            },
            // additionally match actions using predicate (matcher is run
            // against all actions - handled and unhandled earlier)
            match: (
                predicate: (action: Action) => boolean,
                reducer: any,
            ): typeof slice => {
                matchers.push(
                    (state, action) =>
                        predicate(action) ?
                            isWithPayload(action) ?
                                reducer(state ?? initState, action.payload) :
                                reducer(state ?? initState) :
                            state ?? initState,

                );
                return slice;
            },
        };

    // function building actual reducer based on provided builder function
    return (builder) => {

        // build `reducers` object and `matchers` array
        builder(slice);

        // create main (slice) reducer
        const reducer = defaultReducer ?
            create(reducers, defaultReducer) :
            create(reducers);

        // return reducer that is also applying all defined matchers
        return (state, action) => {
            let localState = reducer(state, action);
            for (const match of matchers) {
                localState = match(localState, action);
            }
            return localState;
        };

    };

}