DEV Community

Dominik Weber
Dominik Weber

Posted on • Originally published at domysee.com on

Type-Safe Redux Reducers

The main problem that has to be solved is how to show TypeScript what the concrete action type is after checking the type property of Redux actions.

Straightforward Solution

The most straightforward way to do this is creating a type for every action and using type guards to distinguish them.

Type guards are boolean functions that check if a parameter is a specific type, indicated by the return value being parameter is Type.

Used in a condition, TS assumes the variable passed to the type guard is of the type the guard checks for. Only in the true branch of course.

An example solution with type guards:

interface Action {
  type: string;
}

interface StartAction extends Action {
  date: Date;
}

interface SuccessAction extends Action {
  data: number;
}

interface ErrorAction extends Action {
  error: string;
}

export const createStart: () => StartAction = () => ({
  type: 'Start',
  date: new Date()
});

export const createSuccess = (data: number): SuccessAction => ({
  type: 'Success',
  data
});

export const createError = (error: string): ErrorAction => ({
  type: 'Error',
  error
});

// TYPE GUARDS
const isStartAction = (action: Action): action is StartAction => 
  action.type === 'Start';

const isSuccessAction = (action: Action): action is SuccessAction => 
  action.type === 'Success';

const isErrorAction = (action: Action): action is ErrorAction => 
  action.type === 'Error';

export interface State {
  lastStarted?: Date;
  data?: number;
  error?: string;
}

const defaultState: State = {
  lastStarted: undefined,
  data: undefined,
  error: undefined
};

const reducer = (state = defaultState, action: Action) => {
  if (isStartAction(action)) {
    return {
      lastStarted: action.date,
      data: undefined,
      error: undefined
    };
  }

  if (isSuccessAction(action)) {
    return {
      data: action.data,
      error: undefined
    };
  }

  if (isErrorAction(action)) {
    return {
      data: undefined,
      error: action.error
    };
  }

  return state;
};
Enter fullscreen mode Exit fullscreen mode

This works, but is quite verbose.

For every action, it's necessary to define

  • The action type
  • The action creator
  • The type guard

And whenever an action changes, a property is added/removed/changed, it has to be adapted on 2 places, the specific action interface and the action creator.

It's possible to get rid of both, but that requires a little detour first.

Inferring Concrete Types from Unions

TS has a neat type inference feature. Under specific circumstances, it can infer the concrete type from a union. What I mean is this

const x: A | B = someObject;
if (someCondition) {
  // x is of type A here
}
Enter fullscreen mode Exit fullscreen mode

For this to work A and B must have a property in common. In Redux actions it would be type. This is necessary so that TS knows this property can be accessed in the condition.

Additionally the type property must be a literal type, for example a literal string (e.g. 'Start') or number (e.g. 123).

This type is checked in the condition. Since each type's type property can only be a specific value, TS can infer the type based on the given value.

interface A {
  type: 'Start';
  propA: number;
}

interface B {
  type: 'End';
  propB: number;
}

if (x.type === 'Start') {
  // x is of type A here
}
Enter fullscreen mode Exit fullscreen mode

For this reason it's necessary that the type properties are literal types. If they were a generic string, there'd be nothing to go on for type inference.

Using Unions to Infer the Action Type

Armed with that knowledge, it's possible to get rid of the type guards, in exchange for adding ActionTypes, which is a union of all action types handled in that reducer.

interface StartAction {
  type: 'Start';
  date: Date;
}

interface SuccessAction {
  type: 'Success';
  data: number;
}

interface ErrorAction {
  type: 'Error';
  error: string;
}

type ActionTypes = StartAction | SuccessAction | ErrorAction;

export const createStart = (): StartAction => ({
  type: 'Start',
  date: new Date();
});

export const createSuccess = (data: number): SuccessAction => ({
  type: 'Success',
  data
});

export const createError = (error: string): ErrorAction => ({
  type: 'Error',
  error
});

export interface State {
  lastStarted?: Date;
  data?: number;
  error?: string;
}

const defaultState: State = {
  data: undefined,
  error: undefined
};

export default (state = defaultState, action: ActionTypes) => {
  if (action.type === 'Start') {
    return {
      lastStarted: action.date,
      data: undefined,
      error: undefined
    };
  }

  if (action.type === 'Success') {
    return {
      data: action.data,
      error: undefined
    };
  }

  if (action.type === 'Error') {
    return {
      data: undefined,
      error: action.error
    };
  }

  return state;
};
Enter fullscreen mode Exit fullscreen mode

Removing Action Types with ReturnType

The last improvement to arrive at the final example is automatically inferring action types based on the return value of action creators.

This can be done with the helper type ReturnType.

As the name says, it is a generic type that, if passed a function, returns the type of the returned value. For example

type F = (p1: string, p2: number) => boolean;
type T = ReturnType<F>;
// T = boolean
Enter fullscreen mode Exit fullscreen mode

If you're interested how it works, check out my other blogpost List of Built-In Helper Types in TypeScript.

By using that helper type it's possible to create the union type directly from the return types of the action creators.

One thing to notice here is that the returned objects define the type like this: 'Start' as 'Start'.

This is so the inferred type is of the literal string ('Start'), because by default, when assigning a string, TS infers it to be of type string.

type ActionTypes =
  | ReturnType<typeof createStart>
  | ReturnType<typeof createSuccess>
  | ReturnType<typeof createError>;

export const createStart = () => ({
  type: 'Start' as 'Start',
  date: new Date();
});

export const createSuccess = (data: number) => ({
  type: 'Success' as 'Success',
  data
});

export const createError = (error: string) => ({
  type: 'Error' as 'Error',
  error
});

export interface State {
  lastStarted?: Date;
  data?: number;
  error?: string;
}

const defaultState: State = {
  data: undefined,
  error: undefined
};

export default (state = defaultState, action: ActionTypes) => {
  if (action.type === 'Start') {
    return {
      lastStarted: action.date,
      data: undefined,
      error: undefined
    };
  }

  if (action.type === 'Success') {
    return {
      data: action.data,
      error: undefined
    };
  }

  if (action.type === 'Error') {
    return {
      data: undefined,
      error: action.error
    };
  }

  return state;
};
Enter fullscreen mode Exit fullscreen mode

This is the final example, as concise and with the least typing effort possible.

All solutions are valid though, and depending on the rest of the codebase, the team and your preference you might want to choose a more explicit (first) way to implement type-safe reducers.

Top comments (1)

Collapse
 
martynaskadisa profile image
Martynas Kadiša

Shameless plug: it was a bit of a pain point for me ensuring type safe reducers in a few projects, so I wrote a library for that. It infers types in reducers from action creators.

npmjs.com/package/@reduxify/utils