Search code examples
typescriptreduxfunctional-programmingswitch-statement

Functional switch case with object literal and Typescript


So I've got this classic switch case redux reducer in a todomvc that I want to make functional but can't seem to wrap my head around ts typings for that.

Switch case works great for pattern matching and narrows down action discriminated union by type. But I don't seem to get how to pass around narrowed actions with a functional approach where object literal's key should do a type narrowing.

What I got so far is union type of all functions and some ts errors by the way. Would really appreciate any help on the matter to get a better idea how to use strict types with ts.

import { action as actionCreator } from 'typesafe-actions';
import uuid from 'uuid';

import { ITodo } from 'types/models';

const ADD_TODO = 'todos/ADD_TODO';
const TOGGLE_ALL = 'todos/TOGGLE_ALL';
const REMOVE_TODO = 'todos/REMOVE_TODO';

export const addTodo = (title: string) => actionCreator(ADD_TODO, { title });
export const removeTodo = (id: string) => actionCreator(REMOVE_TODO, { id });
export const toggleAll = (checked: boolean) =>
  actionCreator(TOGGLE_ALL, { checked });

type TodosAction =
  | ReturnType<typeof addTodo>
  | ReturnType<typeof removeTodo>
  | ReturnType<typeof toggleAll>;
type TodosState = ReadonlyArray<ITodo>;

// no idea what typings should be
const switchCase = <C>(cases: C) => <D extends (...args: any[]) => any>(
  defaultCase: D
) => <K extends keyof C>(key: K): C[K] | D => {
  return Object.prototype.hasOwnProperty(key) ? cases[key] : defaultCase;
};

export default function(
  state: TodosState = [],
  action: TodosAction
): TodosState {
  // union type of 4 functions
  const reducer = switchCase({
    // (parameter) payload: any
    // How do I get types for these?
    [ADD_TODO]: payload => [
      ...state,
      {
        completed: false,
        id: uuid.v4(),
        title: payload.title,
      },
    ],
    [REMOVE_TODO]: payload => state.filter(todo => todo.id !== payload.id),
    [TOGGLE_ALL]: payload =>
      state.map(todo => ({
        ...todo,
        completed: payload.checked,
      })),
  })(() => state)(action.type);

  // [ts] Cannot invoke an expression whose type lacks a call signature. Type
  // '((payload: any) => { completed: boolean; id: string; title: any; }[]) |
  // ((payload: any) => ITodo[...' has no compatible call signatures.
  return reducer(action.payload);
}

Solution

  • An interesting typing issue. The first problem regarding the payload types we can solve by passing in the all the possible actions (TodosAction), and requiring that the argument to switchCase must be a mapped type that will contain properties for all types in the union and for each type we can use the Extract conditional type to extract the payload type.

    The second part of the issue is cause by the fact that when you index into a type with a key (that is of a union type itself), you get a union of all possible values from the type. In this case that would be a union of functions, which typescript does not consider callable. To get around this we can change the public signature of the inner function to return a function that will take as an argument a union of all payloads instead of a union of functions that each take a payload.

    The result would look something like this:

    import { action as actionCreator } from 'typesafe-actions';
    import * as uuid from 'uuid';
    
    interface ITodo{
        id: string
    }
    
    const ADD_TODO = 'todos/ADD_TODO';
    const TOGGLE_ALL = 'todos/TOGGLE_ALL';
    const REMOVE_TODO = 'todos/REMOVE_TODO';
    
    export const addTodo = (title: string) => actionCreator(ADD_TODO, { title });
    export const removeTodo = (id: string) => actionCreator(REMOVE_TODO, { id });
    export const toggleAll = (checked: boolean) =>
        actionCreator(TOGGLE_ALL, { checked });
    
    type TodosAction =
        | ReturnType<typeof addTodo>
        | ReturnType<typeof removeTodo>
        | ReturnType<typeof toggleAll>;
    type TodosState = ReadonlyArray<ITodo>;
    
    type Payload<TAll extends { type: any; payload: any }, P> = Extract<TAll, { type: P}>['payload']
    type Casses<T extends { type: any; payload: any }, TState> = { [P in T['type']]: (payload: Payload<T, P>) => TState }
    
    
    const switchCase = <C extends { type: any; payload: any }, TState>(cases: Casses<C, TState>) => 
        <D extends (payload: any) => TState >(defaultCase: D) => {
            function getCase <K extends string>(key: K): (arg: Payload<C, K>) => TState
            function getCase <K extends string>(key: K): Casses<C, TState>[K] | D {
                return cases.hasOwnProperty(key) ? cases[key] : defaultCase;
            }
            return getCase;
        };
    
    export default function (
        state: TodosState = [],
        action: TodosAction
    ): TodosState {
        // union type of 4 functions
        const reducer = switchCase<TodosAction, TodosState>({
            [ADD_TODO]: payload => [
                ...state,
                {
                    completed: false,
                    id: uuid.v4(),
                    title: payload.title,
                },
            ],
            [REMOVE_TODO]: payload => state.filter(todo => todo.id !== payload.id),
            [TOGGLE_ALL]: payload =>
                state.map(todo => ({
                    ...todo,
                    completed: payload.checked,
                })),
        })(() => state)(action.type);
    
        return reducer(action.payload);
    }
    

    Playground link