typesafe-actionsを使って型安心なRedux Storeを実装する
2018-12-08

typesafe-actionsを使って型安心なRedux Storeを実装する

この記事はReact.js その2 Advent Calendar 2018 8日目の記事です。

半年くらい前に、React + Redux + redux-observable + TypeScriptの実践的サンプルという記事を書いたのですが、Reduxのactionとreducerの部分の型があまりうまく定義できてなかったので、個人的に課題だと感じていました。

今回はその部分をtypesafe-actionsを使って型安心に実装する方法の紹介です。

プロジェクト全体のコードはこちらを参照してください。

以前のコード

以前のコードはActionにinterfaceを定義して、型が必要なReducerなどの部分でこのinterfaceを使ってAny型をキャストする方法を取っていました。

// Actionでinterfaceを定義して
interface CoolAction {
  isSuperCool: boolean;
  payload: {};
}
// Reducerの中でキャストする
switch (action.type) {

  case COOL_ACTION:
    return Object.assign({}, state, { 
      cool: new CoolModel((action as CoolAction).payload)
    });

  default:
    return state;
}

この場合、どうしてもActionとReducerの間でAny型となってしまい、型安心とは言えない状態でした。
最近になってreact-redux-typescript-guideを読んでいたら、Redux Store周りを型安心にする方法が載っていたので試してみました。

typesafe-actionsを使って型安心なRedux Storeを実装する

Actionの定義

まずActionを定義します。以前を同じようにcreateActionをつかって型を定義していきます。
resolveの中にActionのpayloadを渡します。これが後にReducerなどで利用されます。

import { createAction } from "typesafe-actions";

import { WEATHER_GET } from "../constants";

export const weatherGetAction = createAction(
  WEATHER_GET, 
  resolve => (lat: number, lng: number) => resolve({ lat, lng }),
);
// これと同じ
// { 
//   type: WEATHER_GET, 
//   payload: { lat: number, lng: number }, 
// }

typesafe-actionsにはactionというもう少し簡単にActionを定義できる関数がありますが、
この場合、getTypeisActionOfなどのHelper関数と一緒に使えないActionとなります。
個人的には、Helper関数を使わない理由があまりないので、面倒でもcreateActionを使うことをおすすめします。

ReducerでActionの型を使う

次にReducerでActionの型の使い型です。
ActionTypeのジェネリックにActionの定義を渡すと、Actionの実行結果の型が返ってきます。これをActionの型として利用します。

import { ActionType, getType } from 'typesafe-actions';

import * as actions from "../actions";

type Action = ActionType<typeof actions>;
// 実際のActionには複数のActionのUnion typeが設定されている
// {  type: WEATHER_GET, payload: { lat: number, lng: number } } | 
// {  type: WEATHER_GET, payload: { weather: Response } }

ActionTypeの中身については非常に難しい部分ですが、簡単に説明するとTypeScriptのReturnType<T>を使って型情報を取得しています。

ReturnType<T>Tに渡された関数を実行してその戻り値の型情報を取得するものです。

例えば、上のactionsの中にweatherGetActionweatherSetActionがあった場合、
ActionType<typeof actions>の結果はweatherGetAction | weatherSetActionのようなUnion typeになります。(実際にはweatherGetAction関数の戻り値の型です。)

次にActionの型情報を取得できたので、これをReducerのActionの型定義として使います。
Typeの判定はgetTypeを使うことで型安心に判定することができます。

export const weatherReducer = (state: WeatherState = initialState, action: Action): WeatherState => {

  switch (action.type) {

    case getType(actions.weatherSetAction):
      // このActionの型は { type: WEATHER_GET, payload: { lat: number, lng: number} }
      return Object.assign({}, state, { weather: new Weather(action.payload) });

    ...

    default:
      return state;
  }
};

以上が基本的な流れです。

おまけ、redux-observableと一緒に使う

typesafe-actionsにredux-observableについての記載があるのですが、これだけだとうまく行かなかったので、うまく行かなかったポイントを紹介します。

Epicの実装

まず、Reducerと同じ方法でActionの型情報を取得しておきます。
Epicの実装は上のtypesafe-actionsのガイドの通りに実装すれば大丈夫です。isOfTypegetTypeを使って型安全に実装していきます。

// ActionはReducerと同じように取得したActionの型情報
export const weatherGetEpic: Epic<Action, Action, RootState> = (action$, store) =>
  action$.pipe(
    filter(isOfType(getType(actions.weatherGetAction))),
    switchMap(action =>
      ...
    )
  );

Storeの実装

Storeの実装の部分は少し変更が必要です。
次のように実装した場合、コンパイル時にエラーがでます。

const epicMiddleware = createEpicMiddleware();
// ちょー長いActionの型定義 is not assignable to parameter of type 'Epic<Action<any>, Action<any>, void, any>'.

ActionとStateの型情報がうまく渡っていないようなので、ジェネリックで型情報を渡すようにするとコンパイルできるようになります。

もしかすると自分のEpicの型定義がどこか間違っているだけかもしれないが。。。

const epicMiddleware = createEpicMiddleware<Action, Action, RootState>();

これでredux-observableと一緒に使っても型安心になりました。

まとめ

説明割愛してしまった部分もありますが、実際に動作しているコードはGitHubを見てください。