TypeScript and Redux Sagas

January 14, 2022

In my exploration of getting proper types for redux-saga effects, I deep dive into understanding more deeply how generators, redux-saga, and typed-redux-saga work

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 returning Promise<string> the resulting value would be a string)
  • 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.

Note: Even you don't mind the verbosity, use `SagaReturnType` instead of this! Keep reading!

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 yielded values, and the second type is the type of the returned 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 yielded 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 yields 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&lt;typeof func&gt; = 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 yielded (iterated over), results in a CallEffect (the description of the call) as the value prop in the object returned by next()
  • when it returns (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-sagas call() because of the line return yield rawCall(...args) which yielded 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 yields instead of having any of its own, there’s no extra yields 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!


Get new posts in your inbox

Profile picture

Written by Marcus Pasell, a programmer who doesn't know anything. Don't listen to him.