そーす

I'm a programmer in Fukuoka. Please contact me saubre.app[at]gmail.com or Twitter DM.

Redux, ReduxThunkを使った時のActionの型導出

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