Typing generator functions in Redux-Saga with Typescript
Asked Answered
S

2

9

Recently I've started refactoring my React project to start using Typescript in it. I've encountered a problem with typing which I don't know how to overcome.

Tech-stack: Typescript, React, Redux, Redux-Saga

I'm also using ESLint with AirBnB ruleset.

I'm trying to write a saga which uses async function defined in my authorization service. The code below is my 2nd attempt to write this. I've splitted the signIn saga in order not to mix the yield return types as suggested on the end of this article.

export function* callSignInService(userCredentials: UserCredentialsInterface): 
    Generator<CallEffect, UserAuthDataInterface, UserAuthDataInterface> > 
{
    return yield call(signInService, userCredentials);
}

export function* signIn(action: PayloadAction<UserCredentialsInterface>): 
    Generator<StrictEffect, void, PutEffect> 
{
    try {
        const { payload } = action;
        const userData = yield* callSignInService(payload);

        yield put(signInSuccess(userData));
    } catch (error) {
        yield put(signInFailure(error.message));
    }
}

A simplified version of the signIn service:

const signIn = async ( userCredentials: UserCredentialsInterface ): Promise<UserAuthDataInterface>
{
    /* ... Some credential format validations ... */
    try {
        const response = await axios.post('someUrl', userCredentials);
        return {
            /* objectContent */
        }: UserAuthDataInterface
    } catch (error) {
        throw new Error(AuthorizationErrorType.SERVER_ERROR);
    }    
}

The problem is I'm still getting an error on:

const userData = yield* callSignInService(payload);

saying :

TS2766: Cannot delegate iteration to value because the 'next' method of its iterator expects type 'UserAuthDataInterface', but the containing generator will always send 'PutEffect'.   Type 'SimpleEffect<"PUT", PutEffectDescriptor>' is missing the following properties from type 'UserAuthDataInterface': user, token

I understand that the issue is that my signIn generator NextType is set to PutEffect and it expects expression yield signInService(userCredentials) to return PutEffect but that's not my goal. I want to use the signInService to get UserAuthData and then put these data into signInSuccess action (signInSuccess is created using action creator).

I've read several resources on how to properly type the generators but still can't get it to work with my example. I found information that redux-saga has or had problems regarding the typing but I am not sure if it's still the case in 2021.

Am I missing some obvious things here? Maybe there is a problem with my attempt to write such Saga. I would be pleased if someone could take a look and give me an advice how to handle this problem.

Sweetbread answered 31/3, 2021 at 19:16 Comment(0)
A
14

The first generic type parameter is the type of the yielded values in the saga. It's the "steps", in other words.

Moving callSignInService to a separate function doesn't actually help with the types because we are still yielding that result.

Your signIn saga has two steps with distinct types -- calling the API and dispatching an action. The generic needs to be a union of the two types.

export function* signIn(
  action: PayloadAction<UserCredentialsInterface>
): Generator<
  // step types
  CallEffect<UserAuthDataInterface> | PutEffect<AnyAction>,
  // return type
  void,
  // intermediate argument
  UserAuthDataInterface
> {
  try {
    const { payload } = action;
    const userData: UserAuthDataInterface = yield call(signInService, payload);

    yield put(signInSuccess(userData));
  } catch (error) {
    yield put(signInFailure(error.message));
  }
}

I'm assuming that your settings require an return type declaration (or else this would be easy!). It can be really helpful to remove the declaration temporarily to see what Typescript infers. That's how I got this solution.

Andra answered 31/3, 2021 at 21:37 Comment(3)
Thank you very much for this solution! I though I tried similiar thing but it turned I didn't. I tried putting union PutEffect | UserAuthDataInterface as step types before and I didn't use call to execute signInService - I was trying to call it like that: const userData = yield signInService(payload). I hope this thread will help someone in the future.Sweetbread
This answer got me 90% of the way, but I have a follow up question hereStoned
Thanks, this is a very good explanation, but I'm surprised at how un-useful the result is. For a Saga, the return value is (almost) always void. Effects are already wired up nicely (i.e., call complains if the args are wrong). If you have more than one selector, you have to cast each result as its type anyway. So if I type my saga as Generator<any, any, any>, and define my selector results like const x: string = yield select(y), it's just as manual, and has less extra info. Am I missing something?Bullbat
B
1

Getting a correct return type from yielded effects in redux-saga bothered me for a long time. Specifying the type manually is not good enough as that gives too much space for error. Since I could not find good solution anywhere, I am sharing solution that I eventually came up with.

We can't get correct return type from regular yield, as it will always be any due to nature of generators. However we can get return type of generator function to whom we delegated control to. So instead of yielding a call effect we will be delegating to another generator function that will only yield the effect and will return us the result of the yielded call effect. In the end, usage will be very similiar, but slightly different.

The helper generator function is typed and implemented, as follows: (all types are native or imported from 'redux-saga/effects').

type SagaGenCall<T extends (...args: any[]) => any> =
    Generator<CallEffect<SagaReturnType<T>>, SagaReturnType<T>, any>

const callSaga =
    function*<Fn extends (...args: any[]) => any>(fn: Fn, ...args: Parameters<Fn>): SagaGenCall<Fn> {
        return yield call(fn, ...args);
    };

To delegate control to another generator function we will be using yield * instead of yield. And you can see resulting types in a following example along with few examples without the helper.

const asyncFnString = async () => 'test';
const asyncFnNum = async () => 5;

const testFn = function*() {
    const test1 = yield asyncFnString();
    // const test1: any

    const test2 = yield call(asyncFnString);
    // const test2: any

    // bad usage example, yielded the generator instead of delegating to it
    // in another word, used 'yield' instead of 'yield*'
    // the code would still work with redux-saga, but the types will not.
    const test3 = yield callSaga(asyncFnString);
    // const test3: any

    const test4 = yield* callSaga(asyncFnString);
    // const test4: string

    const test5 = yield* callSaga(asyncFnNum);
    // const test5: number
};

As you can see the usage is quite ergonomic, as there is no need for manual typing. It will also prevent errors caused by missing manual typing or wrong manual typing. This helper can also be modified for other effects too.

Bibber answered 14/11, 2022 at 10:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.