flowtype
redux

Type Friendly な Reducer 定義を考えて実装してみた

https://qiita.com/mizchi/items/0e2db7c56541c46a7785 を考え直す。
このパターン、明らかにまだ冗長で、しかもFlowもTSもこれでは推論器がよく落ちるという問題があった。
なのでそもそも型が自明なラッパーを定義するのがいいのでは、という発想からスタートしている。

追記: hard-reducer という名前で実装した

既存の reducer 定義の何が面倒臭いか

  • 自明なことを何度も書く
  • 定数値を何度も書いているようで結局 case 文で 一意に紐付いている
  • Action の Union Type の推論が失敗こけやすい (flow も ts もすぐ型をロストする)
  • 巨大 switch case

守るべき reducer の spec とは

  • reducer の (State, Action) => State というインターフェース
    • 最終的に redux.combineReducers できればいいので
  • 中間状態として JSON Serealizable な Action を正整する
  • Flux Standard Action: { type: string, payload }

既存実装の調査

redux-actions

// Action
const { increment, decrement } = createActions({
  'INCREMENT': amount => ({ amount: 1 }),
  'DECREMENT': amount => ({ amount: -1 })
});

//Reducer
const reducer = handleActions({
  [increment](state, { payload: { amount } }) {
    return { counter: state.counter + amount }
  },
  [decrement](state, { payload: { amount } }) {
    return { counter: state.counter + amount }
  }
}, defaultState);

関数参照でマッチする。
動的に生成するには型が付けづらそうなインターフェースをしている。

typescript-fsa http://itexplorer.hateblo.jp/entry/20170720-typescript-fsa-preferavle-to-redux-actions

// Action
const actionCreator = actionCreatorFactory();
export const increment = actionCreator<{amount: number}>('INCREMENT');
export const decrement = actionCreator<{amount: number}>('DECREMENT');

//Reducer
const reducer = reducerWithInitialState(defaultState)
  .case(increment, (action, amount) => ({ ...state, counter: state.counter + amount }))
  .case(decrement, (action, amount) => ({ ...state, counter: state.counter + amount }));

asyncCreator が内部で黒魔術をしていそう。内部でどういう風に action を組み立てるかは書けないようになっている。

今回のゴールの設定

redux-actions の関数参照でマッチするというスタイルと、 typescript-fsa の APIスタイルを真似つつ自明にする。

// usage

const { createAction } = buildActionCreator({ prefix: 'counter/' })
const add = createAction('add', (val: number) => val)
const reducer = createReducer({ value: 0 })
  .case(add, (state, payload) => {
    const p: string = payload // ここの推論が落ちてほしい
    return {
      value: state.value + payload
    }
  })

const action = add(3) // => { type: 'counter/add', payload: 3 }
const ret = reducer({ value: 0 }, add(3))
  • 何度も書くことで煩わしい prefix を付与しておく
    • FlowのUnionType では String を結合すると string 型にもどってしまうが、一回しか書かないなら問題ない
  • createAction された add 関数はただの関数として振る舞う。
  • 関数参照をkeyにコールバックを定義することで定数値を何度も書くのを省ける
    • 実際 switch case の中でも type refinement された後は ほぼ payload だけを触るんだから、既に type の値が自明なら (State, Payload) => State で 処理を書いていいはず

これに型がつくように実装すればよい

どう実装するか

とりあえずで Flow で実装するとして

type ActionCreator<Constraint: string, Input, Payload> = {
  (Input): { type: Constraint, payload: Payload }
}

type Reducer<State> = {
  (State, any): State,
  get: () => Reducer<State>,
  case<Constraint, Input, Payload>(
    ActionCreator<Constraint, Input, Payload>,
    (State, Payload) => State
  ): Reducer<State>,
}

関数ではなく、 Callable な Object として定義して、メタ的に型情報を保存する。
get: () => ... の部分は Flow でそれ以外のメンバにアクセスできるようにするおまじない。アクセッサを踏んだ時にどう型が解決されるかだと解釈している。

function buildActionCreator(opts: { prefix?: string }) {
  const prefix = opts.prefix || ''

  function createAction<Constraint: string, Input, Payload>(
    t: Constraint,
    fn: Input => Payload
  ): ActionCreator<Constraint, Input, Payload> {
    const type = prefix + t
    const fsaFn: any = (input: Input) => {
      return {
        type,
        payload: fn && fn(input)
      }
    }
    fsaFn._t = type
    return fsaFn
  }
  return { createAction }
}

ランタイム用のバックドアを仕込みつつ関数を返す。

実装

/* @flow */
type ActionCreator<Constraint: string, Input, Payload> = {
  (Input): { type: Constraint, payload: Payload }
}

type Reducer<State> = {
  (State, any): State,
  get: () => Reducer<State>,
  case<Constraint, Input, Payload>(
    ActionCreator<Constraint, Input, Payload>,
    (State, Payload) => State
  ): Reducer<State>
}

function buildActionCreator(opts: { prefix?: string }) {
  const prefix = opts.prefix || ''

  function createAction<Constraint: string, Input, Payload>(
    t: Constraint,
    fn: Input => Payload
  ): ActionCreator<Constraint, Input, Payload> {
    const type = prefix + t
    const fsaFn: any = (input: Input) => {
      return {
        type,
        payload: fn && fn(input)
      }
    }
    fsaFn._t = type
    return fsaFn
  }

  function createPromiseAction<Constraint: string, Input, Payload>(
    t: Constraint,
    fn: Input => Promise<Payload> | Payload
  ): ActionCreator<Constraint, Input, Payload> {
    const type = prefix + t
    const fsaFn: any = (input: Input) => {
      return {
        type,
        payload: Promise.resolve(fn(input))
      }
    }
    fsaFn._t = type
    return fsaFn
  }

  function createSimpleAction<Constraint: string>(
    t: Constraint
  ): ActionCreator<Constraint, void, void> {
    const type = prefix + t
    const fsaFn: any = () => ({ type })
    fsaFn._t = type
    return fsaFn
  }

  return { createAction, createSimpleAction, createPromiseAction }
}

function createReducer<State>(initialState: State): Reducer<State> {
  let freezed = false
  const map = new Map()

  const reducer: any = (state: any = initialState, action: any) => {
    const handler = map.get(action.type)
    return handler ? handler(state, action.payload) : state
  }

  reducer.case = (actionFunc, callback) => {
    map.set(actionFunc._t, callback)
    return reducer
  }
  return reducer
}

flow playground

で試せる

TODO:

  • npm の ライブラリとして切り出す
  • TypeScript版を書く
  • 非同期周りやThunk周りを Type Safe に扱える方法がないか考える
  • 異常系の扱い