How to type Redux actions and Redux reducers in Ty

2019-03-15 01:05发布

What is the best way to cast the action parameter in a redux reducer with typescript? There will be multiple action interfaces that can occur that all extend a base interface with a property type. The extended action interfaces can have more properties that are all different between the action interfaces. Here is an example below:

interface IAction {
    type: string
}

interface IActionA extends IAction {
    a: string
}

interface IActionB extends IAction {
    b: string
}

const reducer = (action: IAction) {
    switch (action.type) {
        case 'a':
            return console.info('action a: ', action.a) // property 'a' does not exists on type IAction

        case 'b':
            return console.info('action b: ', action.b) // property 'b' does not exists on type IAction         
    }
}

The problem is that action needs to be cast as a type that has access to both IActionA and IActionB so the reducer can use both action.a and action.a without throwing an error.

I have several ideas how to work around this issue:

  1. Cast action to any.
  2. Use optional interface members.

example:

interface IAction {
    type: string
    a?: string
    b?: string
}
  1. Use different reducers for every action type.

What is the best way to organize Action/Reducers in typescript? Thank you in advance!

18条回答
我欲成王,谁敢阻挡
2楼-- · 2019-03-15 01:24

There are libraries that bundle most of the code mentioned in other answers: aikoven/typescript-fsa and dphilipson/typescript-fsa-reducers.

With these libraries all your actions and reducers code is statically typed and readable:

import actionCreatorFactory from "typescript-fsa";
const actionCreator = actionCreatorFactory();

interface State {
  name: string;
  balance: number;
  isFrozen: boolean;
}

const INITIAL_STATE: State = {
  name: "Untitled",
  balance: 0,
  isFrozen: false,
};

const setName = actionCreator<string>("SET_NAME");
const addBalance = actionCreator<number>("ADD_BALANCE");
const setIsFrozen = actionCreator<boolean>("SET_IS_FROZEN");

...

import { reducerWithInitialState } from "typescript-fsa-reducers";

const reducer = reducerWithInitialState(INITIAL_STATE)
  .case(setName, (state, name) => ({ ...state, name }))
  .case(addBalance, (state, amount) => ({
    ...state,
    balance: state.balance + amount,
  }))
  .case(setIsFrozen, (state, isFrozen) => ({ ...state, isFrozen }));
查看更多
我只想做你的唯一
3楼-- · 2019-03-15 01:29

you can define your action something like:

// src/actions/index.tsx
import * as constants from '../constants'

export interface IncrementEnthusiasm {
    type: constants.INCREMENT_ENTHUSIASM;
}

export interface DecrementEnthusiasm {
    type: constants.DECREMENT_ENTHUSIASM;
}

export type EnthusiasmAction = IncrementEnthusiasm | DecrementEnthusiasm;

export function incrementEnthusiasm(): IncrementEnthusiasm {
    return {
        type: constants.INCREMENT_ENTHUSIASM
    }
}

export function decrementEnthusiasm(): DecrementEnthusiasm {
    return {
        type: constants.DECREMENT_ENTHUSIASM
    }
}

and so, you can define your reducer like follows:

// src/reducers/index.tsx

import { EnthusiasmAction } from '../actions';
import { StoreState } from '../types/index';
import { INCREMENT_ENTHUSIASM, DECREMENT_ENTHUSIASM } from '../constants/index';

export function enthusiasm(state: StoreState, action: EnthusiasmAction): StoreState {
  switch (action.type) {
    case INCREMENT_ENTHUSIASM:
      return { ...state, enthusiasmLevel: state.enthusiasmLevel + 1 };
    case DECREMENT_ENTHUSIASM:
      return { ...state, enthusiasmLevel: Math.max(1, state.enthusiasmLevel - 1) };
  }
  return state;
}

Complete official docs: https://github.com/Microsoft/TypeScript-React-Starter#adding-a-reducer

查看更多
我命由我不由天
4楼-- · 2019-03-15 01:30

For a relatively simple reducer you could probably just use type guards:

function isA(action: IAction): action is IActionA {
  return action.type === 'a';
}

function isB(action: IAction): action is IActionB {
  return action.type === 'b';
}

function reducer(action: IAction) {
  if (isA(action)) {
    console.info('action a: ', action.a);
  } else if (isB(action)) {
    console.info('action b: ', action.b);
  }
}
查看更多
该账号已被封号
5楼-- · 2019-03-15 01:30

To be fair there are many ways to type actions but I find this one very straight forward and has the less possible boilerplate as well (already discussed in this topic).

This approach tries to type the key called "payload" of actions.

Check this sample

查看更多
贼婆χ
6楼-- · 2019-03-15 01:31

The solution @Jussi_K referenced is nice because it's generic.

However, I found a way that I like better, on five points:

  1. It has the action properties directly on the action object, rather than in a "payload" object -- which is shorter. (though if you prefer the "payload" prop, just uncomment the extra line in the constructor)
  2. It can be type-checked in reducers with a simple action.Is(Type), instead of the clunkier isType(action, createType).
  3. The logic's contained within a single class, instead of spread out amonst type Action<TPayload>, interface IActionCreator<P>, function actionCreator<P>(), function isType<P>().
  4. It uses simple, real classes instead of "action creators" and interfaces, which in my opinion is more readable and extensible. To create a new Action type, just do class MyAction extends Action<{myProp}> {}.
  5. It ensures consistency between the class-name and type property, by just calculating type to be the class/constructor name. This adheres to the DRY principle, unlike the other solution which has both a helloWorldAction function and a HELLO_WORLD "magic string".

Anyway, to implement this alternate setup:

First, copy this generic Action class:

class Action<Payload> {
    constructor(payload: Payload) {
        this.type = this.constructor.name;
        //this.payload = payload;
        Object.assign(this, payload);
    }
    type: string;
    payload: Payload; // stub; needed for Is() method's type-inference to work, for some reason

    Is<Payload2>(actionType: new(..._)=>Action<Payload2>): this is Payload2 {
        return this.type == actionType.name;
        //return this instanceof actionType; // alternative
    }
}

Then create your derived Action classes:

class IncreaseNumberAction extends Action<{amount: number}> {}
class DecreaseNumberAction extends Action<{amount: number}> {}

Then, to use in a reducer function:

function reducer(state, action: Action<any>) {
    if (action.Is(IncreaseNumberAction))
        return {...state, number: state.number + action.amount};
    if (action.Is(DecreaseNumberAction))
        return {...state, number: state.number - action.amount};
    return state;
}

When you want to create and dispatch an action, just do:

dispatch(new IncreaseNumberAction({amount: 10}));

As with @Jussi_K's solution, each of these steps is type-safe.

EDIT

If you want the system to be compatible with anonymous action objects (eg, from legacy code, or deserialized state), you can instead use this static function in your reducers:

function IsType<Payload>(action, actionType: new(..._)=>Action<Props>): action is Payload {
    return action.type == actionType.name;
}

And use it like so:

function reducer(state, action: Action<any>) {
    if (IsType(action, IncreaseNumberAction))
        return {...state, number: state.number + action.amount};
    if (IsType(action, DecreaseNumberAction))
        return {...state, number: state.number - action.amount};
    return state;
}

The other option is to add the Action.Is() method onto the global Object.prototype using Object.defineProperty. This is what I'm currently doing -- though most people don't like this since it pollutes the prototype.

EDIT 2

Despite the fact that it would work anyway, Redux complains that "Actions must be plain objects. Use custom middleware for async actions.".

To fix this, you can either:

  1. Remove the isPlainObject() checks in Redux.
  2. Do one of the modifications in my edit above, plus add this line to the end of the Action class's constructor: (it removes the runtime link between instance and class)
Object.setPrototypeOf(this, Object.getPrototypeOf({}));
查看更多
SAY GOODBYE
7楼-- · 2019-03-15 01:33

Here's a clever solution from Github user aikoven from https://github.com/reactjs/redux/issues/992#issuecomment-191152574:

type Action<TPayload> = {
    type: string;
    payload: TPayload;
}

interface IActionCreator<P> {
  type: string;
  (payload: P): Action<P>;
}

function actionCreator<P>(type: string): IActionCreator<P> {
  return Object.assign(
    (payload: P) => ({type, payload}),
    {type}
  );
}

function isType<P>(action: Action<any>,
                          actionCreator: IActionCreator<P>): action is Action<P> {
  return action.type === actionCreator.type;
}

Use actionCreator<P> to define your actions and action creators:

export const helloWorldAction = actionCreator<{foo: string}>('HELLO_WORLD');
export const otherAction = actionCreator<{a: number, b: string}>('OTHER_ACTION');

Use the user defined type guard isType<P> in the reducer:

function helloReducer(state: string[] = ['hello'], action: Action<any>): string[] {
    if (isType(action, helloWorldAction)) { // type guard
       return [...state, action.payload.foo], // action.payload is now {foo: string}
    } 
    else if(isType(action, otherAction)) {
        ...

And to dispatch an action:

dispatch(helloWorldAction({foo: 'world'})
dispatch(otherAction({a: 42, b: 'moon'}))

I recommend reading through the whole comment thread to find other options as there are several equally good solutions presented there.

查看更多
登录 后发表回答