How to type Actions for Redux-Observable's Epics with TypeScript

Viewed 2130

I'm setting TypeScript and Redux-Observable in my app and have this simple flow I'd like to apply here with an Epic:

dispatch('someAction')
fetch('relevant-endpoint-from-api')
getResponse(dispatch('someActionSuccess'))
failed(dispatch('someActionFailed'))

So following this article that is just doing that, I gave it a try.

Here's my actions.ts file:

export interface MediasOrigin {
  id: number;
  label: string;
  url: string;
  zonesId: number[];
  scenariosRef: number[];
  stepsRef: number[];
  gearsId: number[];
  texturesId: number[];
}

export enum MediasActionTypes {
  MEDIAS_ORIGINS_FETCH_ALL = "MEDIAS_ORIGINS_FETCH_ALL",
  MEDIAS_ORIGINS_FETCH_ALL_SUCCESS = "MEDIAS_ORIGINS_FETCH_ALL_SUCCESS",
  MEDIAS_ORIGINS_FETCH_ALL_FAILED = "MEDIAS_ORIGINS_FETCH_ALL_FAILED"
}

export interface IMediasOriginsFetchAllAction {
  type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL;
}

export interface IMediasOriginsFetchAllSuccessAction {
  type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL;
  payload: {
    medias: MediasOrigin[]
  }
}

export interface IMediasOriginsFetchAllFailedAction {
  type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL;
  payload: {
    error: string
  }
}

/* ACTION CREATOR */
export function fetchAllMediaOrigins(): IMediasOriginsFetchAllAction {
  return {
    type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL
  }
}

export type MediasAction = IMediasOriginsFetchAllAction | IMediasOriginsFetchAllSuccessAction | IMediasOriginsFetchAllFailedAction

And then my epic.ts file:

import { from, Observable, of } from "rxjs"
import { map, catchError, switchMap, filter } from "rxjs/operators"
import { isOfType } from "typesafe-actions"
import { Epic } from "redux-observable"

import ApiClient from "agent" // change, wrapper around superagent (http client)
import {IStore} from "reducers"
import {
  MediasAction,
  MediasActionTypes,
  IMediasOrigin
} from "./actions"

const getAllMediasOriginEpic: Epic<MediasAction, MediasAction, IStore> = action$ => {
  const request = new ApiClient();

  return action$.pipe(
    filter(isOfType(MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL)),
    switchMap(action => // change
      from(request.getAllOriginMedias()).pipe( // change
        map((response: IMediasOrigin[]) // change =>
          of({
            type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_SUCCESS,
            payload: { medias: response },
          })
        ),
        catchError((error: Error) // change =>
          of({
            type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_FAILED,
            payload: {error: Error.message}, // change
          })
        )
      )
    )
  )
}

export default combineEpics(getAllMediasOriginEpic)

I'm new to TypeScript so maybe the error seems obvious to aficionados, but I don't get what I've done wrong based on TypeScript's message.

(no longer relevant) The message points at the action$ returned in the epic:

Type 'Observable<IMediasOriginsFetchAllAction | Observable<{ type: MediasActionTypes; payload: { medias: unknown; }; }>>' is not assignable to type 'Observable<MediasAction>'.
  Type 'IMediasOriginsFetchAllAction | Observable<{ type: MediasActionTypes; payload: { medias: unknown; }; }>' is not assignable to type 'MediasAction'.
    Type 'Observable<{ type: MediasActionTypes; payload: { medias: unknown; }; }>' is not assignable to type 'MediasAction'.
      Type 'Observable<{ type: MediasActionTypes; payload: { medias: unknown; }; }>' is missing the following properties from type 'IMediasOriginsFetchAllSuccessAction': type, payload  TS2322

I don't get what I could do to fix this, from the message it seems that all I've done is wrong and that medias is unknown although it's specified as being an array of MediasOrigin in the interface. Am I missing something here?

For information, here are the packages versions:

  • typescript: 4.0.2
  • redux-observable: 1.2.0
  • rxjs: 6.6.3

// Edit: 09/24/2020

I made a couple changes in order to make it simpler and got why the error message was talking about a mediasUnknown: I wasn't telling in the epic that response mapped from the api call was of MediasOrigin[] (renamed IMediasOrigin[] now). Also I removed the api method that was a custom operator of my own. Changes have been brought to the scripts above and signaled with a // change comment.

Here's now the error message:

TypeScript error in /Users/utopiad/Dev/spectral-core/platform/src/components/Medias/epics.ts(11,7):
Type '(action$: ActionsObservable<MediasAction>) => Observable<Observable<{ type: MediasActionTypes; payload: { medias: IMediasOrigin[]; }; }> | { ...; }>' is not assignable to type 'Epic<MediasAction, MediasAction, IStore, any>'.
  Type 'Observable<Observable<{ type: MediasActionTypes; payload: { medias: IMediasOrigin[]; }; }> | { type: MediasActionTypes; payload: { ...; }; }>' is not assignable to type 'Observable<MediasAction>'.
    Type 'Observable<{ type: MediasActionTypes; payload: { medias: IMediasOrigin[]; }; }> | { type: MediasActionTypes; payload: { ...; }; }' is not assignable to type 'MediasAction'.
      Type 'Observable<{ type: MediasActionTypes; payload: { medias: IMediasOrigin[]; }; }>' is not assignable to type 'MediasAction'.
        Type 'Observable<{ type: MediasActionTypes; payload: { medias: IMediasOrigin[]; }; }>' is missing the following properties from type 'IMediasOriginsFetchAllFailedAction': type, payload

From what I get, it seems like there is a conflict between my IMediasOriginsFetchAllSuccessAction and IMediasOriginsFetchAllFailedAction at the payload level which makes one of them not assignable to type MediasAction. But isn't that the whole purpose of the | operator when creating types?

1 Answers

You could solve this by adding as const to your MediasActionTypes.* when returning actions:

const getAllMediasOriginEpic: Epic<MediasAction, MediasAction> = (action$) => {
  const request = new ApiClient();
  return action$.pipe(
    filter(isOfType(MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL)),
    switchMap((action) =>
      from(request.getAllOriginMedias()).pipe(
        map((response: IMediasOrigin[]) => ({
          type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_SUCCESS as const,
          payload: { medias: response }
        })),
        catchError((error: Error) =>
          of({
            type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_FAILED as const,
            payload: { error: error.message }
          })
        )
      )
    )
  );
};

This is because when you use something like this:

map((response: IMediasOrigin[]) => ({
    type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_SUCCESS,
    payload: { medias: response }
  })),

The Observable's type is: { type: MediasActionTypes }, .... As you've seen in your error

Type 'MediasActionTypes' is not assignable to type 'MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_FAILED'

'MediasActionTypes' refers to a union and MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_FAILED to a concrete type. So, by using type: MediasActionTypes.MEDIAS_ORIGINS_FETCH_ALL_SUCCESS as const you're narrowing the type.

Related