Edited at

Flow で Flux データフロー実装に対し最小の型アノテーションで 100% の Type Coverage を得る方法


背景

Flux アーキテクチャは概念で、Redux はそれを薄く実装したライブラリだ。

実装を見るとコード量が少ないことに気が付く。

だからこそ Redux を使ったコードにはプログラマの癖が強く現れるし、コミュニティ上でプラクティスに関する議論が盛り上がるし、ドキュメントが長くもなる。

とはいえ、2019 年現在にもなれば、もうプラクティスは出尽くした感がある1

であれば、これをより堅牢に運用できるよう、うまく型をつける方法についても整理してみようと思う。

なお、非同期 Action の実装には redux-thunkredux-promiseredux-saga も使わず、Vanilla な async/await を使う。こうしたほうが、Flux アーキテクチャの原型がつかみやすく、型が付けるのが楽で、またこの記事にとって本質ではない Middleware の説明も端折れる。

ここで書いた実装や型付けの方式は、私が副業で開発に参加している Findy のプロダクト開発で実運用している。


Flux Standard Action への準拠

Action オブジェクトは、Redux コミュニティにおけるベストプラクティスの一つ Flux Standard Action (FSA) の型に準拠させる。

これに準拠する型 StandardActionT を独自に定義しておく。

型引数 T に Action Type 名、P に payload のデータ型を渡す形で利用する。

declare type StandardActionT<T, P> =

| {|
type: T,
payload: P,
error?: false,
meta?: mixed
|}
| {|
type: T,
payload: Error,
error: true,
meta?: mixed
|}

使用例はこうなる。

const deleteUser = (

userId: number
): StandardActionT<'DELETE_USER', number> => ({
type: 'DELETE_USER', // 'DELETE_USER' 以外の文字列だとエラーになる
payload: userId, // payload の型が number 以外だとエラーになる
// a: 1 // 左のように、 FSA で規定されていない property を持たせるとエラーになる
});

ここまでのサンプル


Action Creator に対する型付け


同期 Action Creator 編

同期 Action Creator 関数に対する型付けについては、上の deleteUser がそれそのものになっている。


非同期 Action Creator 編

async function は Promise を返すので、戻り値型の StandardActionTPromise 型で包む必要がある。

type UserT = {| id: number, name: string |}

const fetchUser = async (
userId: number
): Promise<StandardActionT<'FETCH_USER', UserT>> => {
const res = await axios.get(`/users/${userId}`).catch(e => e.response);
return {
type: 'FETCH_USER',
payload: res.data.user // 注意: ここは API レスポンスの中身であり、型アノテーションによってのみ型情報を持つことができる
};
};

ここまでのサンプル


Reducer に対する型付け

いきなり最終的な結果を見るとアレルギーが出るかもしれないので、段階的に型アノテーションを付与していくようにして説明する。


State 型を付ける

まずは何よりも、Reducer の State の型が明示されていないと、Action Creator との協調や、Component とのつなぎ込みなどの全てが難しくなる。ここの型情報はなんとしても死守したい。

そこで StateT を定義し、引数および戻り値のそれぞれが StateT であることをアノテーションする。

type StateT = $ReadOnlyArray<UserT>;

const users = (state: StateT, action: any /* TODO */): StateT => {
/* 省略 */
}


Reducer が observe する Action 型を定義する(同期編)

この Reducer が関与する Action 型を定義する。

これと State 型を両方与えることで、それぞれの型定義が協調し、実際に完全な型チェックが機能する。

上で例示した同期 Action Creator 関数の deleteUserを用いた例は次のようになる。

/* Entity Types */

type UserT = {| id: number, name: string |};

/* Action creators */

const deleteUser = (
userId: number
): StandardActionT<'DELETE_USER', number> => ({
type: 'DELETE_USER',
payload: userId
});

/* Reducer */

type StateT = $ReadOnlyArray<UserT>;
type ActionT = $Call<typeof deleteUser, *>

const users = (state: StateT, action: ActionT): StateT => {
switch (action.type) {
case 'DELETE_USER': {
if (action.error) {
return state;
} else {
return state.filter(user => user.id !== action.payload);
}
}
default: {
return state;
}
}
};

Action 型定義には、先述した方法で型付けした Action Creator 関数の戻り値の型を、$Call Utility Type を用いて使用する。こうすることで、Action Creator に対する型定義が Single Source of Truth となり、重複する型定義を各所に個別定義する必要がなくせる。

この段階で、完全な型チェックが機能する状態になる。

Action Creator & Reducer 型チェック確認用サンプル(同期 Action のみ)

このサンプルの Reducer 内にあるコメントアウトを外したり、Action Creator の戻り値型を変更してみたりすると、エラーとなることが確認できる。


Reducer が observe する Action 型を定義する(非同期編)

Action Creator 関数が非同期になっても、上の例と同じように、$Call で Action 型を取り出したい。

しかし、async function の戻り値は Promise<T> 型で包まれている。この型パラメータ部分を取り出して、Reducer の受け入れ可能な Action 型として参照するためにはどうすればいいだろう。

これを行う Utility Type を独自に定義することができる。

declare type $UnwrapPromise<T> = $Call<<T>(Promise<T>) => T, T>;

Promise<T> から T を返す関数」の型定義に対して $Call を呼ぶことで、Promise に包まれている型を計算している(参考 issue)。

この型定義をきちんと理解できている必要はなく、とにかく Promise に包まれている型が取り出せていそうなことが $UnwrapPromise 動作確認サンプル で確認できたら、「ふむ、なるほど」などと適当に相槌を打っておこう。

これを用いることで Action Creator を await で呼び出した戻り値型は、以下のように表現できる

$UnwrapPromise<$Call<typeof fetchUser, *>>

最終的に、これを用いた非同期 Action Creator 関数の fetchUser を用いた例は次のようになる。

/* Entity Types */

type UserT = {| id: number, name: string |};

/* Action creators */

const deleteUser = (
userId: number
): StandardActionT<'DELETE_USER', number> => ({
type: 'DELETE_USER',
payload: userId
});

const fetchUser = async (
userId: number
): Promise<StandardActionT<'FETCH_USER', UserT>> => {
const res = await axios.get(`/users/${userId}`).catch(e => e.response);
return {
type: 'FETCH_USER',
payload: res.data.user
};
};

/* Reducer */

type StateT = $ReadOnlyArray<UserT>;
type ActionT =
| $Call<typeof deleteUser, *>
| $UnwrapPromise<$Call<typeof fetchUser, *>>;

const users = (state: StateT, action: ActionT): StateT => {
switch (action.type) {
case 'DELETE_USER': {
if (action.error) {
return state;
} else {
return state.filter(user => user.id !== action.payload);
}
}
case 'FETCH_USER': {
if (action.error) {
return state;
} else {
const user = action.payload;
return state.some(user => user.id === user.id)
? state
: [...state, user];
}
}
default: {
return state;
}
}
};

型パラメータだらけで複雑に見えるが、それぞれを段階的に分解していけば正しい表現になっていることが分かる。一度納得できたら、あとはイディオムとして慣れるのみ。

型チェックが機能する状態については、以下のサンプルで確認できる。

Action Creator & Reducer 型チェック確認用サンプル(非同期 Action Creator 込み)

上記のサンプルで、実際どこまで型がチェックできているかというと、例えば Reducer の各 case 節内に入った時点で action 変数が type refinement されているので FETCH_USER 節の action.payloadUserT 型か Error 型である、といったところまでチェックされていて、完全に型チェックがされている。


まとめ

Action Creator 関数に対してのみ明示的な型アノテーションを付与し、それを Signle Source of Truth とし戻り値型を計算して使用することで、Action Creator 関数と Reducer とそれらの協調する実装に対して、完全な型定義を付与できた。

初見でアレルギーが起きる懸念はあるものの、それぞれの段階を分解して正しいことを一度理解できれば、あとは慣れの問題になる。

ところで近頃は Flow と TypeScript の人気がかなり明確に偏ってきていて、そろそろ TypeScript やっておかないとヤバそうな気配がある。





  1. となったあたりで React Hooks のリリースによりさらなる変化が