TypeScriptでReduxのActionを書くときの煩わしさを軽減するためのモジュールを作った

TypeScriptで型の恩恵を受けられるようにReduxアプリケーションのコードを書こうとすると、同じようなことを繰り返し書かなければならないところがある。顕著なのはAction周りだと思う。同じことを思う人が多いのか、既に楽に書くためのモジュールがたくさんある(ts-action, typescript-fsa, typesafe-actions, などなど)けど、Middlewareが必要だったり、Reducerの書き方を変えなければならなかったりしてどれもしっくりこない。

もっとシンプルに解決できないかなと思ってモジュールを作ってみた。実装としてはTypeScriptで14行、提供するのは2つのAPIのみ。

以下、Redux Async Exampleの例を借りて、pureにTypeScriptで書いたコードとこのモジュールを使って書いたコードを比較してみる。

Action creator and Action interface

ビフォー

Actionのinterfaceと、そのinterfaceを満たすオブジェクトを返す関数を別々に定義する必要がある。

export interface SelectSubreddit {
  type: 'SELECT_SUBREDDIT';
  payload: {
    subreddit: string;
  };
}

export const selectSubreddit = (subreddit: string): SelectSubreddit = ({
  type: 'SELECT_SUBREDDIT',
  payload: {
    subreddit
  }
});

/* ...省略... */

export interface ReceivePosts {
  type: 'RECEIVE_POSTS';
  payload: {
    subreddit: string;
    posts: string[];
    receivedAt: number;
  };
}

export const receivePosts = (subreddit: string, json: any): ReceivePosts => ({
  type: 'RECEIVE_POSTS',
  payload: {
    subreddit,
    posts: json.data.children.map((child: any) => child.data),
    receivedAt: Date.now()
  }
});

使うときは普通に関数呼び出し。

import { requestPosts } from '../actions';

store.dispatch(requestPosts('reactjs'));
アフター

typed-action-classの提供するAction関数を使ってclassを定義することで、実装とinterfaceをまとめられる。また、引数をpayloadに割り当てるだけのAction classであれば、だいたい1行で済む。

import { Action } from 'typed-action-class';

export class SelectSubreddit extends Action('SELECT_SUBREDDIT')<{ subreddit: string }> {}

export class InvalidateSubreddit extends Action('INVALIDATE_SUBREDDIT')<{ subreddit: string }> {}

export class RequestPosts extends Action('REQUEST_POSTS')<{ subreddit: string }> {}

export class ReceivePosts extends Action('RECEIVE_POSTS')<{
  subreddit: string;
  posts: string[];
  receivedAt: number;
}> {
  constructor(subreddit: string, json: any) {
    super({
      subreddit,
      posts: json.data.children.map((child: { data: string }) => child.data),
      receivedAt: Date.now()
    });
  }
}

使うときはnewを使う。

import { RequestPosts } from '../actions';

store.dispatch(new RequestPosts({ subreddit: 'reactjs' }));

United action interfaces

reducerの第2引数の型として使うために、すべてのAction interfaceのUnion型が必要になる。

ビフォー

新しいActionを追加したときに、このUnion型に追加するのをつい忘れてしまう。

// ... action interfaces and creators ...

export type Action = SelectSubreddit | InvalidateSubreddit | RequestPosts | ReceivePosts;
アフター

exportされているAction classから導出できるので、手動で型を列挙しなくていい。

import { UniteActions } from 'typed-action-class';
import * as actions from './creators.ts';

export * as './creators';
export type Action = UniteActions<typeof actions>;

という感じで楽して書けて補完も効いていい感じなんだけど、いくつか気を付けないといけないところがある。

イマイチなところ

ThunkAction(redux-thunk)と混ぜ書きするとUniteActionを使えない

そもそもThunkActionはAction creatorより一つ上のレイヤーのコードなので、このモジュールを使うか否かにかかわらずAction creatorとはファイルを分けた方がいいと思う。

// actions/index.ts

import { UniteActions } from 'typed-action-class';
import * as actions from './creators';

export * as './creators'; // Action classをまとめたファイル
export * as './thunks';   // ThunkActionをまとめたファイル

export type Action = UniteActions<typeof actions>;

これでもAction classとThunkActionで呼び出し方が違うために違和感は残る。

import { ActionClass, aThunkAction } from '../actions';

// Action classはnewで呼び出し
store.dispatch(new ActionClass({}));

// ThunkActionは関数呼び出し
store.dispatch(aThunkAction());

redux-thunkをやめてMiddlewareを手書きしようEpicやSagaといったレイヤーを持つ他のMiddlewareを使おう、というのは原理主義的過ぎるかな……。

ActionオブジェクトはFlux Standard Actionスタイル(action.payload.hoge

もともとはaction.hogeという風に値を格納したかったけど、どうあがいても型の整合性を取ることができなかった。うまくいったと思ったらコンストラクタの引数のシグネチャ(...args: any[])に退化したり、typeがただのstring型になったりで、あちらを立てればこちらが立たずから抜け出せない。

ということでFSAスタイルのaction.payload.hogeという形を採用することにした。FSAのerrormetaといった他のプロパティに対するサポートは特にないけど、必要であれば継承先で自分で定義することができる。

class FetchUserFailure extends Action('FETCH_USER_FAILURE')<Error> { error = true; }

fetch(url)
  .catch(e => dispatch(new FetchUserFailure(e)));

実装詳解

plainなオブジェクトを返すclassを作る

ReduxではActionオブジェクトはplainでなければならない。JavaScriptにおけるclassは任意のオブジェクトを返すことができるので、それを利用する。

class Plain {
  constructor() {
    return {};
  }
}

このclassを継承すると、子classはこの空オブジェクトをインスタンスと見なしてconstructする。結果として、子classが返すオブジェクトもplainになる。

class Child extends Plain {
  member = 'this is member';
  method() {
    console.log('this is method');
  }
}

const child = new Child();
console.log(child.member); // "this is member"
console.log(child.method); // undefined
console.log(Object.getPrototypeOf(child) === Object.prototype); // true

Action classが継承するclassでも同じように実装しているので、plainなオブジェクトでなければエラーが起きるReduxのdispatch(redux/src/createStore.js#L166-L171)を通っても大丈夫。

実はこの例のPlain classもそのままnpmモジュールとして公開している。

classのモジュールから全てのインスタンス型をUnion型として取得する

UniteActionsの実装は次の通り。

export type UniteActions<ActionModule extends { [name: string]: { prototype: any } }> =
  ActionModule[keyof ActionModule]['prototype'] |
  { type: '' }; // for <https://github.com/reactjs/redux/issues/2709>

型引数のActionModuleは、次のようにimportされたAction classの塊の型が渡されることを想定している。

import * as actions from '../creators';

export type Action = UniteAction<typeof actions>;

このtypeof actionsは、次のような型だとみなせる。

type Class<T> = {
  new(...args: any[]): T;
  prototype: T;
};

type typeof_actions = {
  [exportedName: string]: Class <ActionA | ActionB | ActionC | ...>
};

ここでtypeof_actions[keyof typeof_actions]とすると、Classだけが得られる。これは、いずれかのAction型を返すclass型。class型からインスタンスの型を得るには['prototype']を辿ればいいので、次のようになる(型の世界の話なので、実行時のprototypeの形はここでは影響しない)。

type ClassOfAllActions = typeof_actions[keyof typeof_actions];
// Class<ActionA | ActionB | ActionC | ...>;

type AllActions = ClassOfActions['prototype'];
// ActionA | ActionB | ActionC | ...

これを整理してまとめると、UniteActionの定義になる。