Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Do not create union types with Redux Action Types. It's most likely an antipattern.

So, there is a long-standing TypeScript & Redux pattern of creating a union RootAction type that contains all your application's action types. Sometimes, you would also create a lot of reducer-specific sub-unions. I think that this has always been a crutch - and given the options we have today, it's most likely an antipattern. So let's look at why it was used, where it was used and what you can use instead.

Why was it used? A look at typed Reducers.

Classically, these action union types were created to use the Discriminating Union pattern.

This allowed you to do something like write a reducer like

const actionType1 = 'a'
const actionType2 = 'b'

type ActionA = { type: typeof actionType1; payload: { value: string } }
type ActionB = { type: typeof actionType2; payload: { value: number } }

type State = {}
type Actions = ActionA | ActionB
function reducer(state: State, action: Actions): State {
  switch (action.type) {
    case actionType1:
      return { ...state /* action.payload.value is inferred correctly here */ }
    case actionType2:
      return { ...state /* action.payload.value is inferred correctly here */ }
  }
  return state
}

In a world where switch...case reducers were the norm anyways, this seemed like a very handy pattern. It required a little extra work, like collecting all actions together in a central place and defining all those type constants, but those type constants were already defined anyways and due to all that boilerplate, building an extra union type didn't seem so bad.

It all starts with a lie to the compiler.

There is a point about the discriminated union pattern that was missed though: A discriminated union has to be complete for it to work. Otherwise, you will hide errors. In this specific case, it starts with a lie to the compiler: we're telling tsc that this reducer function will only ever be called with an action that is a member of the Actions union. But is it really? No, this reducer will be called by every action that is ever being dispatched in our application. So if we were only combining the action types we cared about, we'd already be lost. But what if we really went thorougly through the whole app, putting every single action type into that union? It would still be incomplete. Your application is not the only source of actions. There's also middleware, popular examples being "connected-react-router" which for example dispatched a '@@router/LOCATION_CHANGE'-typed action or redux-persist, which will dispatch a 'persist/REHYDRATE'-typed action. And even if you went through all middlewares and also added their action types to the union, you'd still be missing the redux-internal 'INIT' action. Well, let's just add that. Want to know a fun fact about INIT? In development mode, that action name is randomized to prevent developers from relying on that redux internal. Bottom line? Your Actions type will never be complete, no matter how hard you try.

So having accepted that, we'll probably do what most people (and, at this time, even the redux documentation on TypeScript) do and just create smaller sub-types, one small union for all actions we want to handle in a specific reducer. So, like in our example above, that type might even only contain two action types. Let's assume it does.

What bugs are actually hidden by that little oversimplification?

Missing return statement

Let's take our reducer from above and just remove that last return statement.

function reducer(state: State, action: Actions): State {
  switch (action.type) {
    case actionType1:
      return { ...state, /* action.prop1 is inferred correctly here */ }
    case actionType2:
      return { ...state, /* action.prop2 is inferred correctly here */ }
  }
-  return state;
}

This is definitely a bug. This reducer will now return undefined in case of that INIT action we discussed above. But TypeScript won't notice. TypeScript assumes that this method will only ever be called with one of two action types - and both of these are handled. Because we are working on what seems (but really is not) a discriminated union here, union exhaustiveness checking kicks in and assumes that there will never any other return path and that this function will always return a State object. Which it obviously does not.

Action shape might not be what it seems to be.

Also, did you notice that all actions in our union have a action.payload.value property? TypeScript will now allow us to directly access that property without any additional checks - after all, it will always be there - surely this method won't be called with any other action. Except for a lot of other actions. And at least that INIT type has no payload property, so we're just one execution of our app away from the runtime error message

Uncaught TypeError: action.payload is undefined

A lie to the compiler is a lie to your team and your future self

Of course, all that doesn't seem too bad. You'll surely remember that you lied there and take all possible precautions that none of those above bugs happen. But will you really, a year or two down the line? Will a new team member that's just getting onboarded and is not used to your little redux-lies yet? You're deliberately disabling TypeScript there, but it is not visible to anyone who doesn't know the pattern. If you were any-casting, at least that would be visible. Here it's not. Don't do that if there are other ways to do the same. But are there?

What to do instead?

The correct tool for this use case has been around for a long type: Type predicate functions.

A naive variation of a type guard function would look like this:

import { AnyAction } from 'redux'

interface SpecificAction extends AnyAction {
  type: 'somethingSpecific'
  payload: string
}

function matchSpecificAction(action: AnyAction): action is SpecificAction {
  return action.type === 'somethingSpecific'
}

function doSomething(action: AnyAction) {
  if (matchSpecificAction(action)) {
    action.payload // this is correctly inferred to `string` here
  }
}

Of course, this does seem to be a lot of code when writing it by hand - but it can be automated. And it can be automated a lot better than manually having to combine action types.

If you are using redux toolkit, all your action creators created by createAction and createSlice will already have such a type guard:

import { createAction } from '@reduxjs/toolkit'
import { AnyAction } from 'redux'

const myAction = createAction<{ value: string }>('my/action')

function doSomething(action: AnyAction) {
  if (myAction.match(action)) {
    action.payload.value // this is correctly inferred to `string` here
  }
}

and if you are not using redux toolkit, you could for example use this helper function that attaches a .match(action) function and a .type property to every action creator you pass into it:

import { AnyAction } from 'redux'

type Matchable<AC extends () => AnyAction> = AC & {
  type: ReturnType<AC>['type']
  match(action: AnyAction): action is ReturnType<AC>
}

function withMatcher<AC extends () => AnyAction>(
  actionCreator: AC
): Matchable<AC>
function withMatcher<
  AC extends (...args: any[]) => AnyAction & { type: string }
>(actionCreator: AC): Matchable<AC>
function withMatcher<AC extends (...args: any[]) => AnyAction>(
  actionCreator: AC,
  type: ReturnType<AC>['type']
): Matchable<AC>
function withMatcher(
  actionCreator: Function & { type?: string },
  _type?: string
) {
  const type = _type ?? actionCreator.type ?? actionCreator().type
  return Object.assign(actionCreator, {
    type,
    match(action: AnyAction) {
      return action.type === type
    }
  })
}

Now you can just wrap your action creator in withMatcher and use it like in the example above!

const createActionA = withMatcher((payload?: string) => {
  return {
    type: 'foo/bar',
    payload
  }
})

Why the additional type you ask? That might come in handy when we talk about redux-saga later ;)

With all this, your Reducer<MyState, MyActions> lie to the compiler becomes a valid Reducer<State, AnyAction> and will probably look something like this:

function reducer(state: MyState, action: AnyAction): MyState {
  if (specificAction.match(action)) {
    return { ...state /* action is correctly inferred here */ }
  }
  if (anotherAction.match(action)) {
    return { ...state /* action is correctly inferred here */ }
  }
  return state
}

The bugs from above? Don't apply any more.

Of course, you can reduce that down quite a bit using createReducer or createSlice from @reduxjs/toolkit, which is the official recommendation and which you really should be using.

With createSlice, it would look like this:

const slice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducer: {
    // will result in an action of type `counter/increment`
    increment(state) {
      // state values can be modified mutably thanks to `immer` integration
      state.value++
    },
    incrementBy(state, { payload }: PayloadAction<number>) {
      state.value += payload
    }
  }
})
export const { increment, incrementBy } = slice.actions // automatically generated action creators with correct types
export default slice.reducer

No matter what you use, your reducers should be covered now. No more lie to the compiler, no more collection type definitions over multiple files in a central location and creating unwieldy unions.

Where was it used as well/what to use instead?

Of course, that union type was used in several patterns with other libraries & use cases as well. Let's look at them!

Redux Observables

Before:

export const epic = (actions$: Observable<MyActions>) =>
  actions$.pipe(
    ofType(INCREMENT),
    map(action => {
      // action is correctly typed here
    })
  )

After

export const epic = (actions$: Observable<AnyAction>) =>
  actions$.pipe(
    filter(increment.match),
    map(action => {
      // action is correctly typed here
    })
  )

So we are just moving from the special case ofType that tried to pull it's own "discriminating union" over the observable action type to the more general filter method that just accepts any type guard function.

Redux Saga

Redux saga already works out-of-the box with action creators that have a .type property for effects like take with another saga as the second argument. So using both redux toolkit action creators as well as the withMatcher-created action creators from above will work just fine without any further modifications.

But, generally redux-saga with TypeScript is a bit of a pain: the result of a yield is just never defined, so you still need the types around for manual casting:

import { take } from 'redux-saga/effects'

function* exampleSaga() {
  // cannot be inferred and needs to be manually typed
  const action: IncrementActionType = yield take(slice.actions.incrementBy)
}

But... did you know that there is a library called typed-redux-saga?

import { take } from 'typed-redux-saga'

function* exampleSaga() {
  // correctly inferred as { payload: number; type: string; }
  const action = yield* take(slice.actions.incrementBy)
}

It slightly changes the semantics of yielding effects: instead of yield, you are now using yield*. But apart from that, everything is the same. And it just works, with type inference at every corner!

Custom Middleware

Custom middleware works just like the reducer example above: switch ... case becomes if ... else with type guards. Nothing to worry about here.

Dispatch

Now, all that said, there is one last valid-ish use for an application-wide MyActions type: Some people want to prevent wrongly typed actions to be passed into dispatch. Yes, I get you. But also: Are you serious? You are going around and hand-writing actions in the first place? My solution: make it a rule in your team to always use action creators. Those will also be typed correctly and work as an equally good solution. All without going through the work of manually gathering all your action types in one place.

By the way: If someone were to write an eslint rule for that, we'd probably be very happy to ship that with redux toolkit as a recommendation ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment