Better TypeScript Types for Redux Saga’s “Call” Effect

Article summary

One of the challenges of working with Redux Saga in TypeScript is the heavy and unconventional use of generator functions. This feature from JavaScript does some amazing heavy lifting in enabling the library to be as powerful as it is, but it just doesn’t play well with TypeScript’s capabilities.

There’s currently no way to even model Saga’s behavior into TypeScript, so any effect you yield produces an any return type, turning your sagas into something approaching JavaScript code in terms of type safety (read: not much).

You can get back into the realm of type safety with some manual type annotations, but these are often a pain to write and maintain. After a few rounds of iteration, we finally have a solution that works well in our codebase to make these manual annotations easier. We can now write:


const aNumber: CallReturnType<typeof getANumber> = yield call(getANumber);

 

And aNumber will be properly inferred as the right type, regardless of whether getANumber simply returns a number directly, returns a promise, or is a saga that returns a number. Your own call effects work the same way; just use the name of the called function in place of getANumber.

Before this, we had an ad hoc approach to manually extracting the type and duplicating it in the definition of the variable. If the function/saga we were calling changed, our types would get out of alignment with the actual behavior, and we’d have subtle type errors that were worse than having no type information at all. At least with any, we know we’re riding without a seatbelt.

I hope this helps your sagas get more type safe and convenient to write and maintain!

Implementation

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.


/** 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>
>;

 

Conversation
  • Cefn Hoile says:

    A better approach is to use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*

    The generator function you yield to can be typed with a narrow return type eliminating the need for any casting between yield and assignment.

  • Comments are closed.