3

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);
}
infctr
  • 357
  • 1
  • 9

2 Answers2

4

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

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Completely missed out that part, but how to enforce the return object type from reducer functions to match `TodosState` type? It doesn't seem to get type checked properly now – infctr Sep 14 '18 at 11:12
0

Not an answer to the original question exactly, but it's adjacent and might help someone stumbling on this from google.


I'm trying to use useReducer with TypeScript. Here's my setup:

interface State {
  loading: boolean;
  // blah blah
}

type Action = ReturnType<typeof setLoading | typeof someOtherAction | type anotherAction>;

// action creator
const setLoading = (value: boolean) => ({
  type: "setLoading" as const,
  payload: value,
});

// some other action creator

const reducer = (state: State, action: Action) => {
  switch (action.type) {
    case "setLoading":
      return { ...state, loading: action.payload };

    // other actions

    default:
      return state;
  }
};

I want to have everything type-safe, and I want to have action-creator functions, and I don't want to have to define my action/payload types multiple times.

The important piece for me was the as const. Without it, TypeScript thinks the return type from the setLoading function is string, instead of the string literal setLoading. I guess because objects are mutable? Anyway, as const makes the type const setLoading: (value: boolean) => { type: "setLoading"; payload: boolean; }, which makes all the types work properly.

V. Rubinetti
  • 1,324
  • 13
  • 21