typescript-fsaしゅごい
そんな便利なtypescript-fsaには以下のCompanion Packagesが同じ作者より提供されています(リンク省略)
typescript-fsa-redux-sagatypescript-fsa-redux-observabletypescript-fsa-redux-thunktypescript-fsa-reducers
この記事ではtypescript-fsa-redux-sagaの使い方について簡単にまとめた記事になります(一部typescript-fsa及びtypescript-fsa-reducersが入ります)。
本記事執筆時におけるバージョンは以下の通りです。
redux@4.0.5
redux-saga@1.1.3
typescript-fsa@3.0.0
typescript-fsa-reducers@1.2.1
typescript-fsa-redux-saga@2.0.0
提供されているAPI
提供されているAPIはreadmeにも書かれていますがbindAsyncActionのみです。
bindAsyncActionはAsyncActionCreatorを受け取ってHigherOrderSagaを返す関数です。
以下のような処理を行います。
- AACを受け取る
- 受け取った
SagaIteratorに引数paramsを渡して起動 -
put(AAC.started({params}))(省略可) - 受け取った
SagaIteratorが処理を終えreturn/throwすると-
returnの場合、yieldしたresultをput(AAC.done({params, result})) -
throwの場合、catchしthrowされたerrorを使ってput(AAC.failed({params, error}))
-
AsyncACtionCreator
AsyncActionCreator(AAC)はtypescript-fsaのactionCreator#asyncで作られるオブジェクトです。
作り方等は私の記事で恐縮ですが
こちら
を参照ください。
HigherOrderSaga
HigherOrderSagaはSagaIteratorを受け取る関数です。
SagaIteratorはredux-sagaにて以下のように定義されています。
export type SagaIterator<RT = any> = Iterator<StrictEffect, RT, any>
Iteratorの定義も見てみます。
interface Iterator<T, TReturn = any, TNext = undefined> {
// NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
return?(value?: TReturn): IteratorResult<T, TReturn>;
throw?(e?: any): IteratorResult<T, TReturn>;
}
つまりnext()した際にIteratorYieldResult<StrictEffect>が返ってくるIterableオブジェクトが来れば良さそうです。
次にStrictEffectについて見てみます。
export type StrictEffect<T = any, P = any> = SimpleEffect<T, P> | StrictCombinatorEffect<T, P>;
export interface SimpleEffect<T, P = any> extends Effect<T, P> {
combinator: false
}
export interface StrictCombinatorEffect<T, P> extends Effect<
T, CombinatorEffectDescriptor<StrictEffect>
> {
combinator: true
}
export interface Effect<T = any, P = any> {
'@@redux-saga/IO': true
combinator: boolean
type: T
payload: P
}
ここから以下のことが分かりました
-
StrictEffectはSimpleEffectとStrictBombinatorEffectの共用体型ということ -
SimpleEffectとStrictBombinatorEffectはEffectを継承しているということ -
EffectはFSAを拡張したものということ
使ってみる
型パワーを実感するために使ってみます。
1秒かけてstringの文字数を返すasync関数をsagaで動かすことを考えてみます。また与えられた文字数が3文字の場合Errorをthrowします。
まずはAACとそのasync関数を定義します。
// いつもの
import actionCreatorFactory from 'typescript-fsa'
const actionCreator = actionCreatorFacory()
// AAC定義
const getLengthAction = actionCreator.async<string, number, Error>('getLengthAction')
// async関数
const getLength = async (s: string): Promise<number> => {
await new Promise(resolve => globalThis.setTimout(resolve, 1000))
if (s.length !== 3) {
return s.length
} else {
throw new Error('error')
}
}
次にsaga workerを定義します。
typescript-fsa-redux-sagaを使わない場合は似たような処理を以下のように書くと思います。
function* getLengthWorker(s: string) {
try {
const result = yield call(getLength, s)
yield put({type: 'SomeActionDone', payload: result})
} catch (e) {
yield put({type: 'SomeActionFailed', payload: e, error: true})
}
}
typescript-fsa-redux-sagaを使う場合は以下のように書きます。
// saga worker
function* getLengthWorker(s: string) {
return yield call(getLength, s)
}
// saga workerをwrapしたworker
const boundGetLengthWorker = bindAsyncAction(getLengthAction)(getLengthWorker)
処理結果をreturnしていないことや例外をcatchしていないのでだいぶシンプルになっていると思います。
参考までに、getLengthWorkerはSimpleEffectを継承したCallEffectを返すcallをyieldし、その結果をreturnしています。これによりIterator<SimpleEffect>のシグネチャを満たしています。
なので
function* getLengthWorker(s: string) {
// return yield call(getLength, s)
return yield s
}
のようにgetLengthWorkerを書き換えるとgetLengthWorkerの型はIterator<string>になりbindAsyncActionのシグネチャを満たさずコンパイルエラーになります。
次にboundGetLengthWorkerの起動方法を見てみます。
function* getLengthHandler(): SagaIterator {
yield takeEvery(getLengthAction.started, function*({payload}) {
yield call(boundGetLengthWorker, payload)
})
}
export function* rootSaga(): SagaIterator {
yield fork(getLengthHandler)
}
getLengthHandlerがgetLengthAction.startedを受け取るとboundGetLengthWorkerを起動します。
payloadはgetLengthAction.startedのpayloadの型なのでここでの型チェックを行うことができます。
以上が大まかな流れです。
注意点
AAC.startedの罠
さて、上記のサンプルには致命的なミスがあります。getLengthHandlerはgetLengthAction.startedを受け取りboundGetLengthWorkerを起動していますが、そのboundGetLengthWorker内部でもgetLengthAction.startedを発火させています。
そのためこのまま実行すると無限ループに陥ってしまいます。 ![]()
これを回避するためには以下の2つの手段が取り得ます。
- saga workerで
put(getLengthAction.started())しない -
takeEveryで受け取るActionを変える
saga workerでput(getLengthAction.started())しない
この方法は非常にスッキリ書けます。以下のようにbindAsyncActionにoptionを渡すだけです。
const boundGetLengthWorker = bindAsyncAction(getLengthAction, {skipStartedAction: true})(getLengthWorker)
takeEveryで受け取るActionを変える
この方法は書き方が冗長になりますがもう少し自由度をもたせることが可能です。
以下のようにsaga起動用のactionを別に定義します。
// AAC定義
type getLengthProp = string
const getLengthAction = actionCreator.async<getLengthProp, number, Error>('getLengthAction')
// trigger用Action
const getLengthOp = actionCreator<getLengthProp>('GetLengthOp')
// ...
function* getLengthHandler(): SagaIterator {
yield takeEvery(hogeAsyncOp, function*({payload}) {
yield call(boundGetLengthWorker, payload)
})
}
このようにすることで例えばtakeEveryした後様々な前処理を行い、その後実行されるboundGetLengthWorkerが起動したタイミングを正確にputしたいというニーズを満たすことができます(すいません、このシチュエーションは相当なレベルのコーナーケースだと思います。他に良い例があれば教えてください・・・)。
なおこの場合でも仮にgetLengthActionのPropsの型とgetLengthOpのPropsの型が異なった場合getLengthHandlerにて型エラーを検知できます。
個人的には特段の事情が無い限りは{skipStartedAction: true}してAAC.startedをdispatchするのが良いと思います。
AAC.failedは型安全ではない
実際に使用する際はtypescript-fsa-reducersを合わせて以下のように使うと思います。
import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { ErrorState } from '../containers/Error'
const failedHandler = (_: unknown, { error }: { error: Error }): ErrorState => {
return {
error: { type: error.name, message: error.message },
}
}
export const errorReducer = reducerWithInitialState({
error: null,
} as ErrorState)
.case(getLengthAction.failed, failedHandler)
reducer内では型安全が保証されています。getLengthAction.failedのpayloadは{ error: Error }が含まれています。これは一見問題なさそうです。
しかし例えばgetLengthActionでbindされたsaga workerでcallしているasync関数(getLength)が以下のようになっている場合どうなるでしょうか。
const getLength = async (s: string): Promise<number> => {
throw 'a'
}
この場合は型エラーが出ません。javascriptでは全てのオブジェクトがthrowableなためです。
そのため丁寧にthrowしないと実行時エラーが起きてしまう危険があります。
終わりに
以上を元にtypescript-fsa-redux-sagaについて簡単にまとめてみます。
- メリット
-
try...catch...のようなボイラープレートを削減できる - 型チェックできる部分が増える
-
- デメリット
- saga worker内で
throwしないといけない
- saga worker内で
個人的な所感ですがthrowする事を許容する/しないという点が採用にあたってのポイントになると思います。
特にsaga内でcallする非同期関数で謎のerrorをthrowされる危険を許容できないのであればtypescript-fsa-redux-sagaを採用せずtry...catch...で手動でAACをputするのも有りだと思います。
また、現状bindAsyncActionはthrowされた時のみAAC.failedをputするという点も覚えておきたいところです。