以前書いた記事で書いたReducerでは、受け取ったActionの型を判別するために、switchで分岐した後に対応したActionの型でキャストをする必要があった。

const todoReducer: Reducer<ITodoState> = (
  state: ITodoState = initTodoState,
  action: TodoAction
): ITodoState => {
  switch (action.type) {
    case TodoActionType.ADD_TODO:
      const addTodoAction: IAddTodoAction = action; // <-- ここでキャストしている
      return {
        ...state,
        todos: state.todos.concat([addTodoAction.payload.todo])
      };
    default:
      return state;
  }
};

この方法だと、action.typeとそれに対応する型を実装者が気にしなければならず、型安全とは言い切れなかった。

このあたりの話を某Slackで相談したら、以下のような Flux standard actionのtypeやpayloadをジェネリクスで指定するようにして、typeにenumを使うというやり方を教えてもらった。

import { Reducer } from 'redux';

// Flux standard action
interface Action<TType, TPayload = null, TMeta = undefined> {
  type: TType;
  payload: TPayload;
  meta?: TMeta;
}

enum ActionType {
  ADD,
  DELETE
}

type AddAction = Action<ActionType.ADD, { todo: string }>;
type DeleteAction = Action<ActionType.DELETE, { todo: string }>;

type TodoAction = AddAction | DeleteAction;

interface TodoState {
  todos: string[];
}

// Reducer
type TodoReducer = Reducer<TodoState, TodoAction>;

const initialState = {
  todos: []
};

const reducer: TodoReducer = (
  state: TodoState = initialState,
  action: TodoAction
): TodoState => {
  switch (action.type) {
    case ActionType.ADD: {
      return {
        ...state,
        todos: [...state.todos, action.payload.todo]
      };
    }
    case ActionType.DELETE: {
      return {
        ...state,
        todos: state.todos.filter(todo => todo !== action.payload.todo)
      };
    }
  }
};

export default reducer;

こうするとSwitchでtypeを引っ掛けた時に型が推論されるため、caseのスコープの中でpayloadの型を確定できる。

(これを知った後にちゃんと調べたらReduxの公式ドキュメント(Usage with TypeScript)に型安全に各やり方が書いてあった……ちゃんとドキュメントは読みましょうということでした……)