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条回答
The star\"
2楼-- · 2019-03-15 01:33

Here is how I do it:

IAction.ts

import {Action} from 'redux';

/**
 * https://github.com/acdlite/flux-standard-action
 */
export default interface IAction<T> extends Action<string> {
    type: string;
    payload?: T;
    error?: boolean;
    meta?: any;
}

UserAction.ts

import IAction from '../IAction';
import UserModel from './models/UserModel';

export type UserActionUnion = void | UserModel;

export default class UserAction {

    public static readonly LOAD_USER: string = 'UserAction.LOAD_USER';
    public static readonly LOAD_USER_SUCCESS: string = 'UserAction.LOAD_USER_SUCCESS';

    public static loadUser(): IAction<void> {
        return {
            type: UserAction.LOAD_USER,
        };
    }

    public static loadUserSuccess(model: UserModel): IAction<UserModel> {
        return {
            payload: model,
            type: UserAction.LOAD_USER_SUCCESS,
        };
    }

}

UserReducer.ts

import UserAction, {UserActionUnion} from './UserAction';
import IUserReducerState from './IUserReducerState';
import IAction from '../IAction';
import UserModel from './models/UserModel';

export default class UserReducer {

    private static readonly _initialState: IUserReducerState = {
        currentUser: null,
        isLoadingUser: false,
    };

    public static reducer(state: IUserReducerState = UserReducer._initialState, action: IAction<UserActionUnion>): IUserReducerState {
        switch (action.type) {
            case UserAction.LOAD_USER:
                return {
                    ...state,
                    isLoadingUser: true,
                };
            case UserAction.LOAD_USER_SUCCESS:
                return {
                    ...state,
                    isLoadingUser: false,
                    currentUser: action.payload as UserModel,
                };
            default:
                return state;
        }
    }

}

IUserReducerState.ts

import UserModel from './models/UserModel';

export default interface IUserReducerState {
    readonly currentUser: UserModel;
    readonly isLoadingUser: boolean;
}

UserSaga.ts

import IAction from '../IAction';
import UserService from './UserService';
import UserAction from './UserAction';
import {put} from 'redux-saga/effects';
import UserModel from './models/UserModel';

export default class UserSaga {

    public static* loadUser(action: IAction<void> = null) {
        const userModel: UserModel = yield UserService.loadUser();

        yield put(UserAction.loadUserSuccess(userModel));
    }

}

UserService.ts

import HttpUtility from '../../utilities/HttpUtility';
import {AxiosResponse} from 'axios';
import UserModel from './models/UserModel';
import RandomUserResponseModel from './models/RandomUserResponseModel';
import environment from 'environment';

export default class UserService {

    private static _http: HttpUtility = new HttpUtility();

    public static async loadUser(): Promise<UserModel> {
        const endpoint: string = `${environment.endpointUrl.randomuser}?inc=picture,name,email,phone,id,dob`;
        const response: AxiosResponse = await UserService._http.get(endpoint);
        const randomUser = new RandomUserResponseModel(response.data);

        return randomUser.results[0];
    }

}

https://github.com/codeBelt/typescript-hapi-react-hot-loader-example

查看更多
Deceive 欺骗
3楼-- · 2019-03-15 01:35

If you need to fix your implementation exactly as you posted, this is the way how to fix it and get it working using type assertions , respectively as I show in the following:

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: ', (<IActionA>action).a) // property 'a' exists because you're using type assertion <IActionA>

      case 'b':
          return console.info('action b: ', (<IActionB>action).b) // property 'b' exists because you're using type assertion <IActionB>
  }
}

You can learn more on section "Type Guards and Differentiating Types" of the official documentation: https://www.typescriptlang.org/docs/handbook/advanced-types.html

查看更多
ら.Afraid
4楼-- · 2019-03-15 01:37

Here's the approach I've taken for this problem:

const reducer = (action: IAction) {

    const actionA: IActionA = action as IActionA;
    const actionB: IActionB = action as IActionB;

    switch (action.type) {
        case 'a':
            // Only ever use actionA in this context
            return console.info('action a: ', actionA.a)

        case 'b':
            // Only ever use actionB in this context
            return console.info('action b: ', actionB.b)
    }
}

I'll be the first to admit there's a certain ugliness and hackiness to this approach, but I've actually found it to work pretty well in practice. In particular, I find that it makes the code easy to read and maintain because the action's intent is in the name and that also makes it easy to search for.

查看更多
▲ chillily
5楼-- · 2019-03-15 01:38

Two parts of the problem

Several comments above have mentioned concept/function `actionCreator´ - take a look at redux-actions package (and corresponding TypeScript definitions), that solves first part of the problem: creating action creator functions that have TypeScript type information specifying action payload type.

Second part of the problem is combining reducer functions into single reducer without boilerplate code and in a type-safe manner (as the question was asked about TypeScript).

The solution

Combine redux-actions and redux-actions-ts-reducer packages:

1) Create actionCreator functions that can be used for creating action with desired type and payload when dispatching the action:

import { createAction } from 'redux-actions';

const negate = createAction('NEGATE'); // action without payload
const add = createAction<number>('ADD'); // action with payload type `number`

2) Create reducer with initial state and reducer functions for all related actions:

import { ReducerFactory } from 'redux-actions-ts-reducer';

// type of the state - not strictly needed, you could inline it as object for initial state
class SampleState {
    count = 0;
}

// creating reducer that combines several reducer functions
const reducer = new ReducerFactory(new SampleState())
    // `state` argument and return type is inferred based on `new ReducerFactory(initialState)`.
    // Type of `action.payload` is inferred based on first argument (action creator)
    .addReducer(add, (state, action) => {
        return {
            ...state,
            count: state.count + action.payload,
        };
    })
    // no point to add `action` argument to reducer in this case, as `action.payload` type would be `void` (and effectively useless)
    .addReducer(negate, (state) => {
        return {
            ...state,
            count: state.count * -1,
        };
    })
    // chain as many reducer functions as you like with arbitrary payload types
    ...
    // Finally call this method, to create a reducer:
    .toReducer();

As You can see from the comments You don't need to write any TypeScript type annotations, but all types are inferred (so this even works with noImplicitAny TypeScript compiler option)

If You use actions from some framework that doesn't expose redux-action action creators (and You don't want to create them Yourself either) or have legacy code that uses strings constants for action types you could add reducers for them as well:

const SOME_LIB_NO_ARGS_ACTION_TYPE = '@@some-lib/NO_ARGS_ACTION_TYPE';
const SOME_LIB_STRING_ACTION_TYPE = '@@some-lib/STRING_ACTION_TYPE';

const reducer = new ReducerFactory(new SampleState())
    ...
    // when adding reducer for action using string actionType
    // You should tell what is the action payload type using generic argument (if You plan to use `action.payload`)
    .addReducer<string>(SOME_LIB_STRING_ACTION_TYPE, (state, action) => {
        return {
            ...state,
            message: action.payload,
        };
    })
    // action.payload type is `void` by default when adding reducer function using `addReducer(actionType: string, reducerFunction)`
    .addReducer(SOME_LIB_NO_ARGS_ACTION_TYPE, (state) => {
        return new SampleState();
    })
    ...
    .toReducer();

so it is easy to get started without refactoring Your codebase.

Dispatching actions

You can dispatch actions even without redux like this:

const newState = reducer(previousState, add(5));

but dispatching action with redux is simpler - use the dispatch(...) function as usual:

dispatch(add(5));
dispatch(negate());
dispatch({ // dispatching action without actionCreator
    type: SOME_LIB_STRING_ACTION_TYPE,
    payload: newMessage,
});

Confession: I'm the author of redux-actions-ts-reducer that I open-sourced today.

查看更多
啃猪蹄的小仙女
6楼-- · 2019-03-15 01:38

Lately I have been using this approach:

export abstract class PlainAction {
    public abstract readonly type: any;
    constructor() {
        return Object.assign({}, this);
    }
}

export abstract class ActionWithPayload<P extends object = any> extends PlainAction {
    constructor(public readonly payload: P) {
        super();
    }
}

export class BeginBusyAction extends PlainAction {
    public readonly type = "BeginBusy";
}

export interface SendChannelMessageActionPayload {
    message: string;
}

export class SendChannelMessageAction
    extends ActionWithPayload<SendChannelMessageActionPayload>
{
    public readonly type = "SendChannelMessage";
    constructor(
        message: string,
    ) {
        super({
            message,
        });
    }
}

This here:

constructor() {
    return Object.assign({}, this);
}

ensures that the Actions are all plain objects. Now you can make actions like this: const action = new BeginBusyAction(). (yay \o/)

查看更多
小情绪 Triste *
7楼-- · 2019-03-15 01:39

To get implicit typesafety without having to write interfaces for every action, you can use this approach (inspired by the returntypeof function from here: https://github.com/piotrwitek/react-redux-typescript#returntypeof-polyfill)

import { values } from 'underscore'

/**
 * action creator (declaring the return type is optional, 
 * but you can make the props readonly)
 */
export const createAction = <T extends string, P extends {}>(type: T, payload: P) => {
  return {
    type,
    payload
  } as {
    readonly type: T,
    readonly payload: P
  }
}

/**
 * Action types
 */
const ACTION_A = "ACTION_A"
const ACTION_B = "ACTION_B"

/**
 * actions
 */
const actions = {
  actionA: (count: number) => createAction(ACTION_A, { count }),
  actionB: (name: string) => createAction(ACTION_B, { name })
}

/**
 * create action type which you can use with a typeguard in the reducer
 * the actionlist variable is only needed for generation of TAction
 */
const actionList = values(actions).map(returnTypeOf)
type TAction = typeof actionList[number]

/**
 * Reducer
 */
export const reducer = (state: any, action: TAction) => {
  if ( action.type === ACTION_A ) {
    console.log(action.payload.count)
  }
  if ( action.type === ACTION_B ) {
    console.log(action.payload.name)
    console.log(action.payload.count) // compile error, because count does not exist on ACTION_B
  }
  console.log(action.payload.name) // compile error because name does not exist on every action
}
查看更多
登录 后发表回答