Redux, Redux-Thunkを使った時のActionの型導出 目的は
const reducer = (state: State = initial, action: Action): State => { ... }
の第2引数のActionの型をできるだけ簡単に導出することです。 具体的にはDucksパターンを用いた設計でActionCreatorからActionTypeやActionの型を別途定義することなく実際のActionCreatorの定義からActionを導出するというものです。 別途定義するというのは Usage With TypeScript · Redux ここに書いてあるようなActionTypeやActionの型を定義することをさします。
通常のReduxで使う同期ActionとReduxThunkで使う非同期Actionの2つに分けて導出していきます。
同期Action
const setUser = (user: User) => ({ type: 'SET_USER', payload: { user }}) const deleteUser = () => ({ type: 'DELETE_USER' }) // setUserの型は以下の用に推論されている const setUser: (user: User) => { type: string; payload: { user: User; }; }
同期Actionというのはこういうやつですね。多分よくあるActionを返すActionCreatorの定義だと思います。これだとtypeはstringならなんでもいいという型になってしまっています。
まずこれのtypeを定数扱いにするために as const
をtypeに付けます。
const setUser = (user: User) => ({ type: 'SET_USER' as const, payload: { user }}) const deleteUser = () => ({ type: 'DELETE_USER' as const })
次にこれらのそれぞれのActionCreatorの型を導出します。typeofというキーワードで型を導出できるのでそれを用います。
const setUser = (user: User) => ({ type: 'SET_USER', payload: { user }}) const deleteUser = () => ({ type: 'DELETE_USER' }) type SetUserType = typeof setUser type DeleteUserType = typeof deleteUser
こうすると例えばSetUserTypeは
type SetUserType = (user: User) => { type: 'SET_USER'; payload: { user: User; }; }
このような型として導出されます。 次にこれはActionを作る関数の型なのでActionの型をこれから導出します。ReturnTypeというGenericsの型があるのでそれを使います。これは関数の型定義からReturnされる型を導出してくれます。
type SetUserType = typeof setUser type DeleteUserType = typeof deleteUser type SetUserActionType = ReturnType<SetUserType> type DeleteUserActionType = ReturnType<DeleteUserType>
これでSetUserActionTypeは
type SetUserActionType = { type: 'SET_USER'; payload: { user: User; }; }
という型として導出されます。 これを1つにまとめるとすると
type Action = SetUserActionType | DeleteUserActionType
この型は
type Action = { type: "DELETE_USER"; } | { type: "SET_USER"; payload: { user: User; }; }
こういう型として導出されます。 毎回これ全部書くのはもちろん面倒なので
type Action = ReturnType<typeof setUser | typeof deleteUser>
これで済みます。 ここまででとりあえずActionの型は導出できましたが、この方法だとActionCreatorが増えるたびにActionの型にも手を加える必要があります。ActionCreatorに追加されたらActionも自動で導出されるようになるのが理想ですね。 まずはそれぞれのActionCretorを1つのオブジェクトとして定義します。
const ActionCreator = { setUser: (user: User) => ({ type: 'SET_USER' as const, payload: { user }}), deleteUser: () => ({ type: 'DELETE_USER' as const }) }
このActionCretorからsetUserの型を導出するには
type SetUserType = typeof ActionCreator['setUser']
このように書けます。 すべてのActionCreatorのKeyに対する型の導出はkeyofを使って
type ActionCreatorType = typeof ActionCreator[keyof typeof ActionCreator]
と書けます。 あとはこれを先程の定義と混ぜると
type Action = ReturnType<typeof ActionCreator[keyof typeof ActionCreator]>
こうすることでActionCreatorに追加するとActionの型定義がちゃんと導出されます。
const ActionCreator = { setUser: (user: User) => ({ type: 'SET_USER' as const, payload: { user }}), deleteUser: () => ({ type: 'DELETE_USER' as const }), setHoge: (fuga: string) => ({ type: "SET_HOGE" as const, payload: { fuga }}) // 新しく追加 } // ActionCreatorにsetHogeを追加するだけでActionには定義がちゃんと導出される type Action = ReturnType<typeof ActionCreator[keyof typeof ActionCreator]>
非同期Action
ここではRedux-Thunkを使った非同期処理時の型導出を行います。 Redux-Thunkを使うと関数をdispatchの引数として渡すことができます。その関数の中でdispatchされるアクションの導出が目的となります。今回は例としてユーザ情報を取得するfetchUserという非同期Actionを考えます。
const fetchUser = (id: string) => async (dispatch, getState) => { dispatch({ type: 'FETCH_USER#START' }) try { const { data } = await axios.get<User>(`/users/${id}`) dispatch({ type: 'FETCH_USER#SUCCESS', result: { user: data } }) } catch(e) { dispatch({ type: 'FETCH_USER#FAILE', error: { errroMessage: "Error" } }) } }
dipatchされるActionは3つです。
- { type: 'FETCH_USER#START' } 非同期アクションの開始
- { type: 'FETCH_USER#SUCCESS', result: { user: data } } 非同期アクション成功
- { type: 'FETCH_USER#FAILE', error: { errroMessage: "Error" } } 非同期アクション失敗
ここで、ReduxThunkに定義されているThunkActionという非同期アクションの型定義を使います。
export type ThunkAction<R, S, E, A extends Action> = ( dispatch: ThunkDispatch<S, E, A>, getState: () => S, extraArgument: E ) => R; export interface ThunkDispatch<S, E, A extends Action> { <T extends A>(action: T): T; <R>(asyncAction: ThunkAction<R, S, E, A>): R; }
これを使うと先程のfetchUserは以下のように書けます。
// reduxのstoreの定義がStoresで、extraArgumentが無いと仮定 const fetchUser = (id: string): ThunkAction<void, Stores, {}, { type: 'FETCH_USER#START' } | { type: 'FETCH_USER#SUCCESS', result: { user: User } } | { type: 'FETCH_USER#FAILE', error: { errroMessage: string } } > => async (dispatch, getState) => { // ThunkActionの第4引数の定義のおかげでdispatchするActionにもチェックが入ります。 dispatch({ type: 'FETCH_USER#START' }) try { const { data } = await axios.get<User>(`/users/${id}`) dispatch({ type: 'FETCH_USER#SUCCESS', result: { user: data } }) } catch(e) { dispatch({ type: 'FETCH_USER#FAILE', error: { errroMessage: "Error" } }) } }
次にこの定義からdispatchされる型を導出します。これを導出するにはinferというキーワードを使って型をキャプチャします。
type AsyncActionType<T> = T extends ThunkAction<any, any, any, infer R> ? R : never
これはAsyncActionTypeのGenerics TがThunkActionを継承している場合、ThunkActionの第4引数の型をinferによってRでキャプチャします。これによりキャプチャされたRがAsyncActionTypeになるという定義です。これを使うとfetchUserがdispatchするActionは以下のように導出できます。
type FetchUserActionTypes = AsyncActionType<typeof ReturnType<typeof fetchUser>> // 導出結果 type FetchUserActionTypes = { type: "FETCH_USER#START"; } | { type: "FETCH_USER#SUCCESS"; result: { user: User; }; } | { type: "FETCH_USER#FAILE"; error: { errroMessage: string; }; }
あとは同期Actionと同じようにAsyncActionCreatorというオブジェクトを作ってAsyncActionCreatorに追加するだけで自動で導出がされるようにします。
const AsyncActionCreator = { fetchUser: (id: string): ThunkAction<void, Stores, {}, { type: 'FETCH_USER#START' } | { type: 'FETCH_USER#SUCCESS', result: { user: User } } | { type: 'FETCH_USER#FAILE', error: { errroMessage: string } } > => async (dispatch, getState) => { // ThunkActionの第4引数の定義のおかげでdispatchするActionにもチェックが入ります。 dispatch({ type: 'FETCH_USER#START' }) try { const { data } = await axios.get<User>(`/users/${id}`) dispatch({ type: 'FETCH_USER#SUCCESS', result: { user: data } }) } catch(e) { dispatch({ type: 'FETCH_USER#FAILE', error: { errroMessage: "Error" } }) } } } type AsyncActionType<T> = T extends ThunkAction<any, any, any, infer R> ? R : never type Action = AsyncActionType<ReturnType<typeof AsyncActionCreator[keyof typeof AsyncActionCreator]>>
まとめ
最後に同期Actionと非同期Actionをまとめると
const ActionCreator = { setUser: (user: User) => ({ type: 'SET_USER' as const, payload: { user } }), deleteUser: () => ({ type: 'DELETE_USER' as const }), setHoge: (fuga: string) => ({ type: 'SET_HOGE' as const, payload: { fuga } }) // 新しく追加 } const AsyncActionCreator = { fetchUser: ( id: string ): ThunkAction< void, Stores, {}, | { type: 'FETCH_USER#START' } | { type: 'FETCH_USER#SUCCESS'; result: { user: User } } | { type: 'FETCH_USER#FAILE'; error: { errroMessage: string } } > => async (dispatch, getState) => { dispatch({ type: 'FETCH_USER#START' }) try { const { data } = await axios.get<User>(`/users/${id}`) dispatch({ type: 'FETCH_USER#SUCCESS', result: { user: data } }) } catch (e) { dispatch({ type: 'FETCH_USER#FAILE', error: { errroMessage: 'Error' } }) } } } type AsyncActionType<T> = T extends ThunkAction<any, any, any, infer R> ? R : never type Action = | ReturnType<typeof ActionCreator[keyof typeof ActionCreator]> // 同期Action | AsyncActionType<ReturnType<typeof AsyncActionCreator[keyof typeof AsyncActionCreator]>> // 非同期Action