TypeScript and Redux Sagas
January 14, 2022
Table of Contents
Motivation
There’s more than enough content out there that explains how to use redux-saga
, so I won’t go into that. (The official documentation even gives enough info to understand the details I talk about here, though I missed it at first so instead I went off the deep end - doh!)
Consider the following example:
/**
* Sample async function
* @returns Promise<string> returns the string "Hello, world!" after a delay of 1s
*/
const getStringAsync = () => {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
resolve("Hello, world!")
}, 1000)
})
}
/**
* Sample Saga
*/
function* mySaga() {
yield take()
const myStringValue = yield call(getStringAsync)
console.log(myStringValue) // Outputs "Hello, world!" to the console after 1s!
}
//...
When I attach this saga to my Redux store, it will run when an action first gets dispatched. The value of myStringValue
will be "Hello, world!"
after one second, which it will then console.log()
. Nothing interesting to see here, this is all covered in the redux-saga
tutorial in depth.
The crux of the issue: When I yield
that call
effect as I did in my example above, TypeScript has no idea what type to infer for myStringValue
:
function* mySaga() {
yield take()
// 'yield' expression implicitly results in an 'any' type because its containing generator lacks a return-type annotation.ts(7057) const myStringValue = yield call(getStringAsync) console.log(myStringValue) // Outputs "Hello, world!" to the console after 1s!
}
Solution 1: Using Type Annotations
Simply “Lie”
I can tell TypeScript what I expect the type of myStringValue
to be, and everything is fixed:
function* mySaga() {
yield take()
const myStringValue: string = yield call(getStringAsync)
console.log(myStringValue) // Outputs "Hello, world!" to the console after 1s!
}
However, if I change the return value of getStringAsync
, then I won’t get a TypeError but the code will break at runtime!
// Misnomer, really returns a number!
const getStringAsync = () => {
return new Promise<number>((resolve, reject) => {
setTimeout(() => {
resolve(0)
}, 1000)
})
}
function* mySaga() {
yield take()
// TypeScript has been fooled!
const myStringValue: string = yield call(getStringAsync)
// Doesn't work at runtime - numbers don't have a toLowerCase() method
// But TypeScript thought it was a string, so no errors at compile time
console.log(myStringValue.toLowerCase())
}
This won’t do at all. It defeats the point of typing. I want TypeScript to save me from errors like this.
Using ReturnType
to “lie” more truthfully
For other effects, using ReturnType<>
can make my lying more truthful. For example, with take()
:
const MY_ACTION = 'MY_ACTION'
const myAction = (param: string) => {
return {
type: MY_ACTION,
payload: param
}
}
function* mySaga() {
// No error, just like the other "lying" example
const action: ReturnType<typeof myAction> = yield take(MY_ACTION)
const myString: string = action.payload
}
This works great!
Now if I change the type of myAction
’s parameter, the return type of it changes, too, and that gets propagated to the value of the action
object inside mySaga
:
const MY_ACTION = 'MY_ACTION'
// myAction now takes a numberconst myAction = (param: number) => { return {
type: MY_ACTION,
payload: param
}
}
function* mySaga() {
const action: ReturnType<typeof myAction> = yield take(MY_ACTION)
// Error: Type 'number' is not assignable to type 'string'.ts(2322) const myString: string = action.payload}
Using CallReturnType
by Drew Colthorp
The problem with call
specifically is that the function passed in can be a number of different things. It can be a generator, an async function that returns a Promise
, or a “normal” function. There’s different cases for return values for this, outlined in the documentation for the call
effect type:
- In the generator case, the resulting value is the child generator’s return value
- In the async case, the resulting value is the value of the resolved
Promise
(eg. for a function returningPromise<string>
the resulting value would be astring
) - In the “normal” function case, the resulting value is the return value of the function
I stumbled upon Drew Colthorp’s blog post for a solution to this. He created a type called CallReturnType
that uses conditional logic to get the return type based on the three different types of functions that the input to call
could be. In his words:
CallReturnType works by mirroring the runtime behavior of call into the type by leveraging conditional types to effectively do decision-making in the type checker (similar to what call does at runtime). It looks at the return type of the provided function type and does its best to produce an answer consistent with the runtime behavior. The typeof operator lets us reference any function in the type to get its type information to power the machinery.
It’s a pretty brilliant solution! However, it does result in having to annotate types all over my sagas like so:
const getStringAsync = () => {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
resolve("Hello, world!")
}, 1000)
})
}
function* mySaga() {
yield take()
// Oof, that's long!
const myStringValue: CallReturnType<typeof getStringAsync> = yield call(getStringAsync) console.log(myStringValue)
}
I wasn’t a huge fan of the verbosity required for this to work, and I wanted to understand the error and problem better, so I kept investigating.
Understanding Saga Internals
The Types of Effects
At first, I thought the issue might be that call
was untyped. But it’s not!
From the redux-saga
repo, the return type of the call
effect is a CallEffect
:
export function call<Fn extends (...args: any[]) => any>(fn: Fn, ...args: Parameters<Fn>): CallEffect
And a CallEffect
is defined as:
export type CallEffect = SimpleEffect<'CALL', CallEffectDescriptor>
In another file, SimpleEffect
is defined as:
export interface SimpleEffect<T, P> {
'@@redux-saga/IO': true
combinator: false
type: T
payload: P
}
All this to say that return value of a call(...)
is an object. This is what redux-saga
is all about, declarative effects describing what will be done but not doing them. This isn’t surprising, but I had to cover the bases.
Note that call(...)
isn’t a generator, and nor is its return value. It’s simply a function that returns an object. I recall that the TypeScript error was about a generator:
'yield' expression implicitly results in an 'any' type because its containing generator lacks a return-type annotation.ts(7057)
It says because its containing generator lacks a return-type annotation. The problem must therefore lie in mySaga
!
The Generator Type
Time to take a step back and review how generator functions work.
Key notes:
- A generator function returns an iterator
- The generator runs code until it encounters a
yield
- The generator continues on the iterator calling
.next()
Hmm. Ok, what do I need to type my Saga then? The TypeScript website lacks a definition for Generators on the documentation for Iterators and Generators (at time of writing). However, the changelog has it from when it was introduced in 3.6.:
interface Generator<T = unknown, TReturn = any, TNext = unknown>
extends Iterator<T, TReturn, TNext> {
next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
return(value: TReturn): IteratorResult<T, TReturn>;
throw(e: any): IteratorResult<T, TReturn>;
[Symbol.iterator](): Generator<T, TReturn, TNext>;
}
(I also found the change that added the implicit any error we’re getting).
Instead of digging through docs, I could also create a generator with different types to explore how TypeScript infers the type parameters:
// Hovering over this in an IDE like VSCode
// shows TypeScript infers the return type as
// Generator<number, string, unknown>
function* myGenerator(arg: string) {
yield 1
return ""
}
Ok, so the first type parameter to the Generator type is the type of yield
ed values, and the second type is the type of the return
ed values. Adding the types of the yielded effects to the generator type fixes the error, but the type of myStringValue
is unknown
!
function* mySaga(): Generator<TakeEffect | CallEffect, void> {
yield take()
// No error, but myStringValue type is unknown!
const myStringValue = yield call(getStringAsync)
console.log(myStringValue)
}
Wait, isn’t that the third type argument to the Generator
type? Also, how does the yield
ed call()
return a string at all if it returns a declarative object?
How next()
works
From the documentation on MDN, I knew that calling .next()
on an iterator (in my case, the iterator that the generator returns) returns an object with the properties value
and done
that give us the current value and whether we’re done iterating respectively. What I forgot, is that next()
can take an argument which will be used as the value inside the generator as the value of the yield! From [the “Advanced generators” section] on MDN:
The next() method also accepts a value, which can be used to modify the internal state of the generator. A value passed to next() will be received by yield .
This is made abundantly clear by the documentation on redux-saga
where they describe how to test sagas:
A value must then be returned to assign to the action constant, which is used for the argument to the put effect:
const color = 'red'; assert.deepEqual( gen.next(chooseColor(color)).value, put(changeUI(color)), 'it should dispatch an action to change the ui' );
Note the call to gen.next(...)
with the action that then gets used within the generator as what is returned when calling yield take(CHOOSE_COLOR)
.
This is what makes redux-saga
so great - it’s easily testable due to this inversion of control and declarative effects. When testing a saga with a call
for example, the test can assert that the saga attempts to call that function without ever calling that function, and specify what it wants the saga to get back as the return value of that call. Beautiful!
I can define the acceptable types for the arguments to next()
using the third type argument of the Generator
type. For example:
function* mySaga(): Generator<TakeEffect | CallEffect, void, string> {
yield take()
// TypeScript knows that myStringValue is a string!
const myStringValue = yield call(getStringAsync)
console.log(myStringValue)
}
Nice! Everything works great! Does this solve my initial problem? Well, not quite.
What if I want to call
another method, maybe one that returns a number? I’d have to use string | number
in the type for my generator, which now makes the types of my variables ambiguous
const getStringAsync = () => {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
resolve("Hello, world!")
}, 1000)
})
}
const getNumberAsync = () => {
return new Promise<number>((resolve, reject) => {
setTimeout(() => {
resolve(1234)
}, 1000)
})
}
function* mySaga(): Generator<TakeEffect | CallEffect, void, string | number> {
yield take()
// myStringValue is of type string | number
const myStringValue = yield call(getStringAsync)
// myNumberValue is of type string | number
const myNumberValue = yield call(getNumberAsync)
// This will error!
// Property 'toLowerCase' does not exist on type 'string | number'.
// Property 'toLowerCase' does not exist on type 'number'.ts(2339)
console.log(myStringValue.toLowerCase())
}
I’d be forced to go back to “lying”. It’s even more verbose than just having the ReturnType
annotations. I would prefer if I could let TypeScript infer everything for me, so I don’t have to annotate all of my sagas and variables.
Solution 2: typed-redux-saga
There exists a package that solves these type issues in redux-saga
called typed-redux-saga
. It promises to infer all the types for effects exactly as you’d expect. No annotations necessary, and all you have to do is use their package instead of redux-saga
and use yield*
instead of yield
. It works great out of the box, but I’m going to explore and see if I can understand how it works.
How yield*
works
The MDN article explains it better than me, but from what I understand, it simply delegates to the child generator.
Looking at the source for typed-redux-saga
shows that they import the normal saga effects and then wrap them like this:
import {
// ...
call as rawCall,
// ...
} from "redux-saga/effects";
export function* call(...args) {
return yield rawCall(...args);
}
I’ll try a simple example, mimicking what it looks like, to understand why I need yield*
:
// Simple "CallEffect" mock
function rawCall() {
return { type: "CALL", payload: {} }
}
// Mimicking what typed-redux-saga does
function* call() {
return yield rawCall();
}
// Mimicking what typed-redux saga says to do
function* yieldStar() {
const myFoo = yield* call();
console.log(myFoo)
}
// What happens when I don't yield*?
function* yieldNormal() {
const myFoo = yield call();
console.log(myFoo)
}
const iterator = yieldNormal();
console.log(iterator.next().value) // Outputs an "empty" object {}
iterator.next("hi") // Continues the function, hits the console log, logs "hi"
const iterator2 = yieldStar();
console.log(iterator2.next().value) // Outputs the call object { type: "CALL", payload: {} }
iterator2.next("hi") // Continues the function, hits the console log, logs "hi"
What’s happening here? Why does yieldNormal
look like an empty object? Well, it’s not - running in the console, I can see that it actually is the generator itself:
call {<suspended>}
[[GeneratorLocation]]: VM461:7
[[Prototype]]: Generator
[[GeneratorState]]: "suspended"
[[GeneratorFunction]]: ƒ* call()
[[GeneratorReceiver]]: Window
[[Scopes]]: Scopes[3]
This makes sense, because I yield
the return value of call()
which is the iterator, rather than calling call()
to get the iterator and then calling next()
on it to get it to run. To actually execute and propagate the yield
s of the child generator up to the parent, I have to use yield*
.
Exploring the call()
generator type
But why does typed-redux-saga
wrap the effects into generators in the first place?
I took a look at the types for typed-redux-saga
. Here’s one for the call
function:
export function call<Fn extends (...args: any[]) => any>(
fn: Fn,
...args: Parameters<Fn>
): SagaGenerator<SagaReturnType<Fn>, CallEffect<SagaReturnType<Fn>>>;
Earlier in the file, SagaGenerator
is defined like so:
export type SagaGenerator<RT, E extends Effect = Effect<any, any>> = Generator<
E,
RT
>;
I had to look at the ts3.6 directory’s effect.d.ts of the real redux-saga
for SagaReturnType
- which makes sense, since that’s the version of TypeScript that introduced the better Generator types as I discovered above. Here’s SagaReturnType
’s definition:
export type SagaReturnType<S extends Function> =
S extends (...args: any[]) => SagaIterator<infer RT> ? RT :
S extends (...args: any[]) => Promise<infer RT> ? RT :
S extends (...args: any[]) => infer RT ? RT :
never;
Déjà Vu! CallReturnType
vs SagaReturnType
Hey, wait a minute, SagaReturnType
looks familiar! It looks very similar to Drew’s CallReturnType
from his blog post I mentioned above:
/** Strip any saga effects from a type; this is typically useful to get the return type of a saga. */
type StripEffects<T> = T extends IterableIterator<infer E>
? E extends Effect | SimpleEffect<any, any>
? never
: E
: never;
/** Unwrap the type to be consistent with the runtime behavior of a call. */
type DecideReturn<T> = T extends Promise<infer R>
? R // If it's a promise, return the promised type.
: T extends IterableIterator<any>
? StripEffects<T> // If it's a generator, strip any effects to get the return type.
: T; // Otherwise, it's a normal function and the return type is unaffected.
/** Determine the return type of yielding a call effect to the provided function.
*
* Usage: const foo: CallReturnType<typeof func> = yield call(func, ...)
*/
export type CallReturnType<T extends (...args: any[]) => any> = DecideReturn<
ReturnType<T>
>;
/** Get the return type of a saga, stripped of any effects the saga might yield, which will be handled by Saga. */
export type SagaReturnType<T extends (...args: any[]) => any> = StripEffects<
ReturnType<T>
>;
It’s the same concept, SagaReturnType
already does what CallReturnType
does! Neat! Indeed, if I annotate my variables with SagaReturnType
it functions the same as CallReturnType
:
function* mySaga() {
yield take()
// myStringValue is of type string
const myStringValue: SagaReturnType<typeof getStringAsync> = yield call(getStringAsync)
// myNumberValue is of type number
const myNumberValue: SagaReturnType<typeof getNumberAsync> = yield call(getNumberAsync)
console.log(myStringValue.toLowerCase(), myNumberValue)
}
If I don’t want to go down the route of using typed-redux-saga
for whatever reason, this should be a great alternative.
Understanding the call()
generator
Getting back to understanding how their call()
generator function is typed:
export function call<Fn extends (...args: any[]) => any>(
fn: Fn,
...args: Parameters<Fn>
): SagaGenerator<SagaReturnType<Fn>, CallEffect<SagaReturnType<Fn>>>;
export type SagaGenerator<RT, E extends Effect = Effect<any, any>> = Generator<
E,
RT
>;
I consider my async function getStringAsync
from before, but this time using typed-redux-saga
and with yield*
const myStringValue = yield* call(getStringAsync)
I’m already familiar with the concept of SagaReturnType
from CallReturnType
, so I know that for example the SagaReturnType
of getStringAsync
is a string
. Thus per the above definition of call()
, the type of the call()
generator in my example is SagaGenerator<string, CallEffect<string>>
, which by the above definition for SagaGenerator
is the same as Generator<CallEffect<string>, string>
.
What does this mean? The type of typed-redux-saga
’s call
generator in my use case is a generator function that:
- when
yield
ed (iterated over), results in aCallEffect
(the description of the call) as thevalue
prop in the object returned bynext()
- when it
return
s (completes iteration), returns a string
This is where the magic happens. By wrapping the original call
effect into a generator, they have given it two different types it can use unambiguously. TypeScript knows the type of myStringValue
in my example is not a CallEffect
because by using yield*
, I delegated the yield
(whose value is a CallEffect
) to the caller (in redux’s case, the middleware), and won’t get a value from the call()
generator until it returns (which is after the subsequent call to next()
). It instead infers the type of myStringValue
using the return
of the call()
generator, which is defined as the SagaReturnType
of the method called. In my case, the method is getStringAsync
which as I said before has a SagaReturnType
of a string
.
By delegating that CallEffect
up to the middleware, everything looks exactly the same to the middleware and it calls .next()
with the string that resulted from the function call just exactly as it would before. This is the value that is then returned by typed-redux-saga
s call()
because of the line return yield rawCall(...args)
which yield
ed the call effect first, and then returns the resulting value which was passed in by the middleware via next()
after resuming. My saga gets that string with no issue, and since it delegated yield
s instead of having any of its own, there’s no extra yield
s to the generator than there otherwise would have been before.
Despite only having minimal wrapping of function calls, typed-redux-saga
also takes it a step further if I use typed-redux-saga/macros
by using Babel to remove the wrappings at compile time, turning yield*
back into yield
and importing directly from redux-saga
.
A simplified example
This is a simplified version of what’s happening in redux-saga
:
function call() {
return 123
}
function* mySaga() {
const result = yield call()
console.log(result)
}
const iterator = mySaga()
// Logs 123 - this is the value the middleware gets.
// Execution of the saga is paused at `const result = yield call()`
console.log(iterator.next().value)
// Passes "Hello world" in as the result of `yield call()`
// Now `result` is "Hello world"
// Execution continues, mySaga logs "Hello world"
iterator.next("Hello world")
And here’s what that would look like functionally if using typed-redux-saga
function* call() {
const value = yield 123
return value
}
function* mySaga() {
const result = yield* call()
console.log(result)
}
const iterator = mySaga()
// Logs 123 - this is the value the middleware gets.
// Execution of the saga is paused at `const value = yield 123`
console.log(iterator.next().value)
// Passes "Hello world" in as the result of `yield 123`
// Now `value` is "Hello world"
// Execution continues, and mySaga gets the resulting "Hello world" and logs it
iterator.next("Hello world")
Notice how the iteration looks exactly the same - the middleware didn’t have to change at all, and there’s no extra steps running the generator. However, because the call()
is now a generator, I can add some types like so to match the types in the type definitions of typed-redux-saga
:
function* call(): Generator<number, string> {
const value = yield 123
return value // Error! Type 'unknown' is not assignable to type 'string'.ts(2322)
}
function* mySaga() {
// Now TypeScript knows this is a string!
const result = yield* call()
console.log(result)
}
const iterator = mySaga()
// Logs 123 - this is the value the middleware gets.
// Execution of the saga is paused at `const value = yield 123`
console.log(iterator.next().value)
// Passes "Hello world" in as the result of `yield 123`
// Now `value` is "Hello world"
// Execution continues, and mySaga gets the resulting "Hello world" and logs it
iterator.next("Hello world")
I get an error because I didn’t define the third type parameter for call()
, which is used for the type of the arguments allowed in next()
, or in other words, the type the yield
statements will evaluate to. It defaults to unknown
and thus value
is unknown, and TypeScript complains I don’t return the string
I said it’d be in the second type parameter for call()
. Notably, these types match typed-redux-saga
as it also leaves the third type parameter as unknown, and it must be by design that they don’t strictly type it!
Watch what happens when I try to type that third parameter, and have two different generators expecting different types to be called on next()
:
function* call(): Generator<number, string, string> {
const value = yield 123
return value
}
function* anotherCall(): Generator<number, number, number> {
const value = yield 123
return value
}
function* mySaga() {
// TypeScript knows this is a string!
const result = yield* call()
// TypeScript knows this is a number!
const anotherResult = yield* anotherCall()
console.log(result, anotherResult)
}
const iterator = mySaga()
console.log(iterator.next().value)
// Error!
// Argument of type '[string]' is not assignable to parameter of type '[] | [never]'.
// Type '[string]' is not assignable to type '[never]'.
// Type 'string' is not assignable to type 'never'.ts(2345)
iterator.next("Hello world")
The third type parameter (the next()
arg type) inferred for the mySaga
generator is [] | [never]
! This is because call()
expects the next()
arg to be a string
, and anotherCall()
expects the next()
arg to be a number
, and since mySaga
delegates to both, it has to try to find a type that fits both of those types. The intersection is impossible, so there’s never
a type that fits!
Strictly typing every next() call’s args is impossible. It would require TypeScript to have a way to infer which iteration we’re on when calling next()
, as each iteration would expect a different next()
arg type. The closest I can get is by making the third type parameter for child generators as any
, and again typing the main saga with everything that’s possible:
function* call(): Generator<number, string, any> {
const value = yield 123
return value
}
function* anotherCall(): Generator<number, number, any> {
return yield 23
}
function* mySaga(): Generator<number, void, string | number> {
const result = yield* call()
const anotherResult = yield* anotherCall()
console.log(result, anotherResult)
}
const iterator = mySaga()
console.log(iterator.next().value)
iterator.next("Hello world")
Even with that, yet again I’d have to be careful to not call next()
with a number first and a string second. It’s probably not worth adding typing to the generators at all, as it doesn’t provide much safety.
How does typed-redux-saga
get around this? Well, since typed-redux-saga
is written in JavaScript but with .d.ts
type definitions, it doesn’t have to specify the third generator type as any
. It allows them to declare that the return type is precisely what they want, despite not having control over what gets passed into next()
, and without getting type errors. In other words, typed-redux-saga
is “lying” just about as much as my initial annotations, but at least by wrapping call()
can infer the types, making it very safe and allowing me to write code without having to manually annotate.
Despite the “lying”, thankfully because typed-redux-saga
is using the types directly from redux-saga
and has versioned redux-saga
internally, it has a known, trusted behavior from which these types were derived and these types are extremely safe. Since using yield*
is much less verbose than using type annotations, I recommend typed-redux-saga
.
Thanks for reading! If you found this helpful, consider subscribing for more!