Redux-saga is a widely used library that helps to deal with side effects in a front-end app. Typing sagas properly is not obvious though. Here’s a guide on how to do it!
What are sagas
Sagas are functions that return generators:
1function* hello(foo: string) {}2// -> (foo: string) => Generator
Even if redux-saga
relies on other concepts (like effects), typing a saga mostly boils down to typing a generator.
Typing a generator
Generators typing was improved in TS 3.6.
The current Generator
type has three type parameters. Some examples will help you in finding out what they describe.
Inferred type for a simple generator factory
1function* thisIsAGenerator(foo: boolean) {2 yield "hello world";3 return 0;4}5// -> Generator<string, number, unknown>
This example allows us to understand what the first two type arguments stand for in a generator type:
- The first one is the type of the values we yield. We’ll call it
YieldType
- The second one is the type of the generator’s return value. We’ll call it
ReturnType
If values of different types are yielded, YieldType
is the union of each possible type
1function* thisIsYetAnotherGenerator(foo: string) {2 yield "hello world";3 yield ["foo", "bar"];4 return 0;5}6// -> Generator<"hello world" | string[], number, unknown>
Where inferred generator types fall short
Consider this generator:
1function* thisOneIsABitDifferent(foo: string) {2 const bar = yield "baz";3 console.log(bar);4 return 0;5}6// -> Generator<string, number, unknown>
Inspecting the type of the bar value turns out to be quite weird.
Indeed, it is of type any
when it could be expected to be of type string
.
Having any
values pop somewhere in TS code is really a pain since it limits a lot the help provided by typing later in the function.
Why can’t TS infer the type of bar
automatically?
To understand it, it is necessary to dig a bit deeper in generator behaviour.
Let’s consider this code from the TS 3.6 release blogpost:
1function* counter() {2 let i = 0;3 while (true) {4 const stop = yield i++;5 if (stop) {6 break;7 }8 }9 return "done!";10}
What is the type of stop
here? If your answer is number
, you’re wrong.
Actually, stop
could a boolean, a string, an object. From all we know according to this code, it could be of any type. It depends on how the generator is used.
The yield
keyword
Let’s refer to yield
’s documentation:
The yield keyword is used to pause and resume a generator function.
The yielded value defines the new current value of the generator. It can be seen in the example they provide:
1function* foo(index) {2 while (index < 2) {3 yield index++;4 }5}67const iterator = foo(0);89console.log(iterator.next().value);10// expected output: 01112console.log(iterator.next().value);13// expected output: 1
What is the stored value when the result of the yield
operator is saved then? The saved value actually is the argument provided to the iterator.next
function.
Using the previously defined counter
generator:
1const iter = counter();2let curr = iter.next();3while (!curr.done) {4 console.log(curr.value);5 curr = iter.next(curr.value === 5);6}7console.log(curr.value.toUpperCase());89// prints:10//11// 012// 113// 214// 315// 416// 517// done!
Here, next
is called with a boolean value (curr.value === 5
). Which means stop
in the definition of counter
is of type boolean
.
Knowing how a generator is used is necessary to know what type yield
operations return.
The full typing of a generator
It is now possible to fully type a generator:
1function* counter(): Generator<number, "done!", boolean>;
counter
successive values are numberscounter
returns"done!"
when there are no new values to generatecounter
takes boolean values as an argument to generate new values
This example will not compile since there’s a conflict on the type of bar
:
1function* thisIsAWrongSaga(foo: string): Generator<string, number, boolean> {2 const bar: string = yield "baz";3 // Type 'boolean' is not assignable to type 'string'.4 console.log(bar);5 return 0;6}
Typing a saga
Everything up to this point works for any kind of generator factory. However, redux-saga
is not limited to writing generator factories.
The library does all the heavy lifting related to orchestrating the sagas (i.e. running effects and calling next
).
Using StrictEffect
Sagas introduce the concept of effects.
Here is the inferred type for a saga that uses the delay
effect:
1function* thisIsASaga(foo: string) {2 yield delay(1000);3 return 0;4}5// -> Generator<SimpleEffect<"CALL", CallEffectDescriptor<true>>, number, unknown>
Here’s a quick look at @types/redux-saga
’s definition of a SimpleEffect
:
1export interface Effect<T = any, P = any> {2 "@@redux-saga/IO": true;3 combinator: boolean;4 type: T;5 payload: P;6}78export interface SimpleEffect<T, P = any> extends Effect<T, P> {9 combinator: false;10}
redux-saga
basically yields simple objects describing what actions should be done without actually doing them.
That’s why sagas are supposed to be easily testable.
Limiting saga yields to effects (including call
effects) allows to test values that do not depend on the implementation of external functions.
@types/redux-saga
provides a StrictEffect
type that can be used in generator types to allow nothing but effects in yields:
1const wiiiiiiiiii = () => "hihihihihi";2function* thisIsAWrongSaga(foo: string): Generator<StrictEffect, number, any> {3 yield wiiiiiiiiii();4 // Type 'string' is not assignable to type 'StrictEffect<any, any>'.5 return 0;6}
Handling forks
Some effects are supposed to return specific values. See fork
’s documentation for instance:
The result of yield fork(fn …args) is a Task object. An object with some useful methods and properties.
Knowing how to type a generator, this is now easy to handle:
1function* thisIsASagaThatForks(2 foo: string3): Generator<StrictEffect, number, Task> {4 const potato = yield fork(function*() {5 yield "Bepis";6 });7 cancel(potato);89 return 0;10}
Limitation of this typing process
This way of typing comes with a few constraints. One of them is the handling of different yield return types.
1const createPotato = () => "potato";2const createTomato = () => false;34export function* thisYieldsManyStuff(): Generator<5 StrictEffect,6 string,7 string | boolean8> {9 const potato = yield call(createPotato);10 const tomato = yield call(createTomato);11 return "yay!";12}
potato
and tomato
will both have the same type which is string | boolean
.
Splitting sagas in smaller bits should allow to overcome this issue in most cases.
The yield*
operator
This is yield*
’s defintion:
The
yield*
expression is used to delegate to another generator or iterable object.
This operator allows to chain generators. An added bonus is that it provides a good typing of the result value:
1function* secondaryGenerator() {2 yield delay(1000);3 return true;4}5// -> Generator<SimpleEffect<"CALL", CallEffectDescriptor<true>>, boolean, unknown>67function* mainGenerator(): Generator<StrictEffect, void, string> {8 const foo = yield call(wiiiiiiiiii); // string9 const secondaryGeneratorResult = yield* secondaryGenerator(); // boolean10 console.log(secondaryGeneratorResult);11}
Combining this with proper saga splitting should allow you to get a perfect typing every single time!
Practice!
Here is a small typescript sandbox with some of the examples given in this article. Playing with it may help better understanding how all of this works. As with a lot of things, practice makes perfect!