10
11

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-01-25

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 に扱える方法がないか考える
  • 異常系の扱い
10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11