https://qiita.com/mizchi/items/0e2db7c56541c46a7785 を考え直す。
このパターン、明らかにまだ冗長で、しかもFlowもTSもこれでは推論器がよく落ちるという問題があった。
なのでそもそも型が自明なラッパーを定義するのがいいのでは、という発想からスタートしている。
既存の 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
で 処理を書いていいはず
- 実際 switch case の中でも type refinement された後は ほぼ payload だけを触るんだから、既に type の値が自明なら
これに型がつくように実装すればよい
どう実装するか
とりあえずで 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
}
で試せる
TODO:
- npm の ライブラリとして切り出す
- TypeScript版を書く
- 非同期周りやThunk周りを Type Safe に扱える方法がないか考える
- 異常系の扱い