Support for typed actions (Typescript) #992

Closed
nickknw opened this Issue Nov 2, 2015 · 61 comments

Projects

None yet
@nickknw
nickknw commented Nov 2, 2015

Related to these two issues:

Hi Dan,

The company I am with is using Redux as well as Typescript, and we would like to add some additional compile-time guarantees to our actions. Currently this isn't possible because of the restriction that actions must be plain objects. You said that this is because 'record/replay and associated features won't work with an action class' and I get that those are valuable features and don't want to lose them. But I had code using Typescript classes that (when the redux devtools were enabled) could still record, cancel and replay actions just fine (using the code I posted in issue 355).

When I remove the devtools I just get errors from that check that actions are plain objects and execution stop there. To me it seems like it is too strict, checking more than is really required. I would much rather have a check that requires that the actions are serializable. I know performance is a concern - IMO it shouldn't have to guarantee/prove they are serializable, just check that the action objects themselves say that they are, and then trust them.
(Not sure why the check doesn't also fail when devtools are enabled - maybe you can shed light on that).

I would really like to be able to leverage the advantages of both Typescript and Redux. Hope you can help me (and others using both of these tools) arrive at a solution.

I also want to reiterate that (as much as I wish it were otherwise!) Typescript does not have discriminated unions, so solutions based on that approach will not work.

Thank you!

@gaearon gaearon added the discussion label Nov 2, 2015
@gaearon
Member
gaearon commented Nov 2, 2015

Does TypeScript let you type-check shapes of plain objects instead of defining classes?
My only issue is with using instances of classes for actions.

Wouldn't TypeScript interfaces be enough to strongly type plain object actions?

@nickknw
nickknw commented Nov 2, 2015

Almost - the problem with those is that they only exist at compile-time. So the reducer will have no way to tell which incoming action is of which interface. ...Unless I keep the 'type' property on each action and then manually cast them after checking that property. And at that point I'm not getting any extra type-safety over just treating them as plain objects.

@alitaheri

@nickknw Well there can be no way to strong type those actions! I mean it wouldn't be possible even if it was C#! It's just how the architecture is, it doesn't have anything to do with redux. I my self use redux with typescript too. And best I could come up with was having interfaces with the same name as my actionTypes, keep the interfaces and constants in the same place, enforce my actionCreators to respect the typings and cast inside the reducers. It's not so hard to maintain and it gives me the type safety I need with clear intentions why the interfaces exist. I would suggest you to consider this approach, it worked for me ๐Ÿ˜ and my project is kinda huge :D

@gaearon
Member
gaearon commented Nov 2, 2015

You said that this is because 'record/replay and associated features won't work with an action class' and I get that those are valuable features and don't want to lose them. But I had code using Typescript classes that (when the redux devtools were enabled) could still record, cancel and replay actions just fine (using the code I posted in issue 355).

Yes but then if you use persistState() enhancer, serialize them, and later restore your state and deserialize them, they will no longer work because your code is relying on instanceof checks.

The โ€œplain objectsโ€ isn't really the point. What we want to enforce is that you don't rely on actions being instances of specific classes in your reducers, because this will never be the case after (de)serialization.

@gaearon
Member
gaearon commented Nov 2, 2015

In other words, this is an anti-pattern:

function reducer(state, action) {
  if (action instanceof SomeAction) {
    return ...
  } else {
    return ...
  }
}

If we make it possible for TypeScript users, I know JavaScript users will be tempted and will abuse this because many people coming from traditional OO languages are used to writing code this way. Then they will lose benefits of record/replay in Redux.

One of the points of Redux is to enforce some restrictions that we find important, and that, if not enforced, don't allow for rich tooling later on. If we let people use classes for actions, they will do so, and implementing something like redux-recorder will be impossible because there's no guarantee people use plain object actions. Especially if boilerplate projects suddenly decide to adopt classes because โ€œwow, itโ€™s allowed now, it must be a great ideaโ€.

@nickknw
nickknw commented Nov 2, 2015

What we want to enforce is that you don't rely on actions being instances of specific classes in your reducers, because this will never be the case after (de)serialization.

I think this is where you lost me. I don't follow why serializing and deserializing an object necessarily means that it will lose its class-related information.

@gaearon
Member
gaearon commented Nov 2, 2015
let actions = [new SomeAction(), new OtherAction()];
let json = JSON.stringify(actions);
let revivedActions = JSON.parse(json);
// reducers no longer understand revivedActions because type info is stripped.
@gaearon
Member
gaearon commented Nov 2, 2015

Another reason why I don't want to allow people to create classes for actions is because this will encourage folks to

a) Create class hierarchies
b) Put logic there, e.g. โ€œpatchโ€ method from https://github.com/rackt/redux/issues/437#issue-99905895

Both are contrary to how I intend Redux to be used. However React showed that if you give people ES6 classes, they will use inheritance even when there are much better alternatives, just because it's so entrenched in software development. It's hard to let go of old habits unless you're forced to.

It's sad that TypeScript forces you to turn things you want to strongly type into classes, but I believe this to be a fault of TypeScript, not ours. As described in https://github.com/rackt/redux/issues/992#issuecomment-153138035, I find casting to be the best solution we can offer. You can also publish redux-typescript-store project with createStore() that doesn't have this restriction, but I'm still not compelled to remove it.

@gaearon
Member
gaearon commented Nov 2, 2015

Finally: for the record, I'm not hatin' on classes.
That's not my point.

My only point is that actions should be passive descriptions of what happened. If we allow them to be class instances, people will build weird abstractions on top of actions with classes, and suffer as a result.

Do not want.

@nickknw
nickknw commented Nov 2, 2015

Ahh okay, thank you for the example. I didn't realize that JSON.stringify discards prototype information. It doing that makes sense to me in retrospect, given the limitations of what JSON can store.

One of the points of Redux is to enforce some restrictions that we find important, and that, if not enforced, don't allow for rich tooling later on.

Yes, I get that, am on board with it, and don't want to compromise on the guarantees the tools can count on. I don't want to create class hierarchies or attach logic to classes, just want to be able to tell them apart at compile-time.

It's sad that TypeScript forces you to turn things you want to strongly type into classes, but I believe this to be a fault of TypeScript, not ours.

Interfaces work well enough for what they were designed for, but I agree it is unfortunate for this use case that they don't keep any type information around at runtime (or allow for a runtime check 'does this conform to this interface' in another way).

My only point is that actions should be passive descriptions of what happened.

100% agree with you here

If we allow them to be class instances, people will build weird abstractions on top of actions with classes, and suffer as a result.

Ugh, I suppose that's true too.


I still think there might be room for a middle ground here, with a slightly more flexible way of serializing actions. There's the 'reviver' and 'replacer' parameters for parse and stringify - maybe there is a way to create a serialization function that can preserve enough information for me to pass some basic type information through but also know enough about the structure to warn/error about the scenarios you want to prevent.

Would rather not go down the route of manually casting actions - I might if nothing else works out but at that point the types aren't adding a lot of value for the effort.

@nickknw
nickknw commented Nov 2, 2015

And yes it would be nice if Typescript had more flexible typing options so that I didn't have to use classes but that isn't something I can realistically affect or count on changing any time soon.

@gaearon
Member
gaearon commented Nov 2, 2015

maybe there is a way to create a serialization function that can preserve enough information for me to pass some basic type information through but also know enough about the structure to warn/error about the scenarios you want to prevent.

This would involve user implementing functions that map type strings to classes which is pretty much manual casting all over again. And we don't want users to implement this sort of stuff to get record/replay working. This is another point of Redux: you get hot reloading, record/replay for free, without implementing serialize(), hydrate(), etc.

@nickknw
nickknw commented Nov 4, 2015

This would involve user implementing functions that map type strings to classes which is pretty much manual casting all over again.

Ideally it would just involve implementing a pair of functions that do that, and then all the action classes would be implemented in a way that those functions could serialize them. So instead of possibly introducing human error once for each different action handled, it would just be once.

And we don't want users to implement this sort of stuff to get record/replay working.

Not what I was proposing - this could be something that is opt-in. If you don't need to do it everything works as normal. I understand you might not be enthusiastic about opening that can of worms. I don't mind digging into this at some point and see if I can find some way to preserve the type information I want without opening the door wide open for everything else.

This is another point of Redux: you get hot reloading, record/replay for free, without implementing serialize(), hydrate(), etc.

This is a great benefit of Redux and I wouldn't want to change that. But I would also love to get some compile-time guarantees for actions while using Redux.

@gaearon
Member
gaearon commented Nov 4, 2015

I don't want to change it in core for reasons above. As I said previously, please feel free to create redux-typescript package with TS-friendly version of createStore and (maybe?) other Redux functions. We don't intend to have breaking changes in Redux at this point so you shouldn't worry about keeping up with the API. And as long as the API matches, such custom store will work just fine. (And its implementation is tiny, check out createStore source).

@gaearon gaearon closed this Nov 4, 2015
@Matthias247

I think you can solve the issue by using TypeScript 1.6 user-defined type guards (see http://blogs.msdn.com/b/typescript/archive/2015/09/16/announcing-typescript-1-6.aspx).

Define an interface for the base action and one for each other action. Additonally for each Action create a isXYZAction(a: Action) function which checks the type based on the value of the type field (which is defined in the base action interface.

Use these functions in the reducer to safely access fields of the specific actions after the check:

interface Action {
  type: string
}

interface AddTodoAction extends Action {
  text: string
}

function isAddTodoAction(action: Action): action is AddTodoAction {
  return action.type == ADD_TODO;
}

function reducer(state: any, action: Action) {
  if (isAddTodoAction(action)) {
    // typesafe access to action.text is possible here
  } else {
    return ...
  }
}
@frankwallis

@Matthias247 that's exactly what I need, thanks!

@nickknw
nickknw commented Nov 17, 2015

@Matthias247 That's fantastic, thank you for pointing that out! Did not know about that feature, that is exactly what I need :)

@aikoven
Member
aikoven commented Dec 22, 2015

I'm using FSA and I found it very convenient to have generic Action interface with type parameter corresponding to payload type:

interface Action<T>{
  type: string;
  payload: T;
  error?: boolean;
  meta?: any;
}

Then I export payload type alongside action type using same name:

export const ADD_TODO = 'ADD_TODO';
export type ADD_TODO = {text: string};

This way when you import ADD_TODO, you actually import both action type and payload type:

import {ADD_TODO} from './actions';
import {handleActions} from 'redux-actions';

const reducer = handleActions({
  [ADD_TODO]: (state, action:Action<ADD_TODO>) => {
    // enabled type checking for action.payload
    // ...
  }
});
@Ben-G Ben-G referenced this issue in ReSwift/ReSwift Dec 26, 2015
Closed

Stronger types for actions #8

@igorbt
igorbt commented Dec 30, 2015

I had the same problem, and found the solution here: http://www.bluewire-technologies.com/2015/redux-actions-for-typescript/. It's basically a generalization of solution proposed by @Matthias247, and it has a clear advantage: no need to define a is*Action() function for each action.

This solution seems to work, but it needs 2 additions for Redux. First you need a middleware for transforming actions to plain objects (redux does this checking and throws an error). This could be as simple as:

// middleware for transforming Typed Actions into plain actions
const typedToPlain = (store: any) => (next: any) => (action: any) =>  {
    next(Object.assign({}, action));
};

And if you use tslint, it will complain about unused _brand property, so the only thing you can do is to enclose it in tslint ignore comments:

/* tslint:disable */
private _brand: NominalType;
/* tslint:enable */

BTW, I didn't use NominalType as it seemed to add to the boilerplate, and just used directly void type.

@AlexGalays

For those interested in a 'flux' lib with native support for typescript, you may want to check: https://github.com/AlexGalays/fluxx#typescript-store

@sompylasar

@AlexGalays Good job so far! Didn't you think of instead forking Redux and adding TypeScript support to it, so the community gets all the features Redux and its friend modules provide, like DevTools, middleware, sagas etc?

UPD Oh, I see, this https://github.com/rackt/redux/issues/992#issuecomment-153662848 answers my question. Then why fluxx and not just redux-typescript? And is it compatible with Redux-based tools?

UPD2 By looking more deeply, the only addition to Redux is the typings and tsconfig.json -- can't we get a repo with only these, and reuse and delegate maintenance of Redux to Redux devs, to avoid having all this callbacks and Dispatch in the middle of dispatch and Store.log implemented in the TypeScript-oriented library?

@AlexGalays

@sompylasar I asked myself that same question at some point: "Why not just use redux, it looks pretty close to what I want from a state container"; But 1) a bit of competition is always good (Speaking in general. fluxx obviously get obliterated by redux competition wise), 2) I wish to keep it simple and easy to learn for my coworkers. That's what I get with fluxx that I don't get with redux: Just one npm dependency for all my needs (yet it's even tinier than redux, you can check!), less boilerplate (the actions are self dispatching and it's very quick to write new ones) and the guarantee that my typescript definitions stay up to date (I don't believe in external definitions except for things like react where you can be fairly sure various people will keep it up to date)

On the other hand I have no need for many things redux and its various extensions provide.

By the way, my abyssa router is also getting typescript definitions soon and I've found working with the combination of abyssa/fluxx/immupdate to be very enjoyable.

@gaearon
Member
gaearon commented Feb 9, 2016

less boilerplate (the actions are self dispatching and it's very quick to write new ones)

FWIW, from http://redux.js.org/docs/recipes/ReducingBoilerplate.html:

Actions are plain objects describing what happened in the app, and serve as the sole way to describe an intention to mutate the data. Itโ€™s important that actions being objects you have to dispatch is not boilerplate, but one of the fundamental design choices of Redux.

There are frameworks claiming to be similar to Flux, but without a concept of action objects. In terms of being predictable, this is a step backwards from Flux or Redux. If there are no serializable plain object actions, it is impossible to record and replay user sessions, or to implement hot reloading with time travel. If youโ€™d rather modify data directly, you donโ€™t need Redux.

@gaearon
Member
gaearon commented Feb 9, 2016

One interesting competing project that is TypeScript friendly is https://github.com/ngrx/store
It is not fully Redux-compatible but people like using it with Angular.

@AlexGalays

@gaearon I know why you did it, I'm not saying "redux could have less boilerplate". I've just chosen a different set of tradeoffs :p

@AlexGalays

https://github.com/ngrx/store is using typescript but is not type-safe at all. You can dispatch anything, and you have no type hint in the update function(s).

@gaearon
Member
gaearon commented Feb 9, 2016

I've just chosen a different set of tradeoffs :p

๐Ÿ‘

You can dispatch anything, and you have no type hint in the update function(s).

Thanks, it seems that you are right. Would you like to bring this up with them? I wonder whether this is intentional.

@gaearon
Member
gaearon commented Feb 19, 2016

Please help review the new official typings in #1413.

@jonaskello

Here is a combination of the above ideas of type-guards and type name same as string const:

The advantage is that you only need one typeguard function which is generic and can be used across all reducers. Not sure if it works though, have not used it in real world projects :-).

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

const FOO_ACTION = 'FOO_ACTION';
type FOO_ACTION = { foo:string };
const BAR_ACTION = 'BAR_ACTION';
type BAR_ACTION = { bar:number };

// This is the only type-guard function needed since it is generic
function is<TPayload>(action: Action<any>, actionName:string): action is Action<TPayload> {
    return action.type == actionName;
}

function reducer(state:string, action:Action<any>) {
    if(is<FOO_ACTION>(action, FOO_ACTION)) {
        // Type-safe access to payload
        const x:string = action.payload.foo;
        //...
        return state;
    }
    else if(is<BAR_ACTION>(action, BAR_ACTION)) {
        // Type-safe access to payload
        const x:number = action.payload.bar;
        // ...
        return state;
    }
}
@aikoven
Member
aikoven commented Mar 2, 2016

My latest approach allows to eliminate boilerplate for specifying types along action type strings:

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

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

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

This way I can set up action by a single statement:

const fooAction = actionCreator<{foo: string }>('FOO_ACTION');

And use it in reducers:

const reducer = (state: State, action: Action<any>): State => {
  if (isType(action, fooAction)) {
    // action payload type is inferred to `{foo: string}`
  }

  return state;
}

I also add extra check to actionCreator function to ensure that action types are unique.

@jonaskello

@aikoven This looks like an interesting approach. So basically the action creator function is responsible for carrying the string constant around. If you want to use redux-saga or similar and need the string constant to do take(), you can import the action-creator foo and then use foo.type to get the string constant. The only thing I don't understand is how you do this part:

I also add extra check to actionCreator function to ensure that action types are unique.

@geon
geon commented Mar 2, 2016

@aikoven

Edit: String vs. string is a problem. Not sure if that can be fixed.

Importing the action creator to the reducer seemed a bit unintuitive to me. It's nice to not have to define a constant string at all, though.

How about typing the constant string with a generic instead?

interface ActionType<TPayload> extends String {}

type Action<TPayload> = {
    type: ActionType<TPayload>,
    payload: TPayload
}

interface ActionCreator<P> {
    (payload: P): Action<P>;
}

function actionCreator<TPayload>(type: ActionType<TPayload>): ActionCreator<TPayload> {
    return (payload) => ({
        type,
        payload
    });
}

export function isType<TPayload>(
    action: Action<any>, 
    type: ActionType<TPayload>
): action is Action<TPayload> {
    return action.type === type;
}

The type property in the action is still just a string, but it is typed to be a generic of the type i want to guard for.

// In actions.ts
export const FOO: ActionType<{ foo: string }> = 'FOO';

export const createFoo = actionCreator(FOO);

Using it is very clear:

// In reducer.ts
const reducer = (state: State, action: Action<any>): State => {
    if (isType(action, FOO)) {

        let foo:string = action.payload.foo;
    }

    return state;
}
@aikoven
Member
aikoven commented Mar 2, 2016

@jonaskello

If you want to use redux-saga or similar and need the string constant to do take(), you can import the action-creator foo and then use foo.type to get the string constant.

Exactly.

The only thing I don't understand is how you do this part:

I also add extra check to actionCreator function to ensure that action types are unique.

Simple:

const actionTypes = {};

export function actionCreator<P>(type: string): ActionCreator<P> {
  if (actionTypes[type])
    throw new Error(`Duplicate action type: ${type}`);

  actionTypes[type] = true;

  return Object.assign(
  // ...

@geon That's clever, although it still involves boilerplate to define action type. I don't see any drawbacks in importing action creators to reducers. In fact, all my action creators are simple pure functions and they encapsulate action type string and action payload type, nothing more. And both are used in reducers, so it seems fine to me.

@jonaskello

@aikoven Thanks, that's what I guessed you meant :-). Btw, how do you handle actions that have no payload? Just setting the payload type to any does not seem like a nice solution...

@igorbt
igorbt commented Mar 2, 2016

@geon @aikoven @jonaskello I'm not sure if your solutions solve the nominal typing problem that I mentioned above. Basically the question is if type checking would tell apart actions with same shape? The problem is there are a lot of cases when actions with completely different purpose have the same payload shape.

@jonaskello

@igorbt The type-guard function looks at the action.type string to tell which type the action has. So the shape should not matter.

@igorbt
igorbt commented Mar 3, 2016

@jonaskello you are right! I was mislead by the use-case for nominal typing illustrated here which is not quite relevant for Redux. So, thanks for pointing me in the right direction on how to simplify things. I don't have FSA actions in my app, and also I have a lot of actions that has only type with no data, so I adapted a bit from @geon's solution, so that's a more generic solution IMHO:

interface ActionType<TAction> extends String {}

interface Action {
    type: string;
}

interface FooAction extends Action {
    foo: string;
}

interface BarAction extends Action {
    bar: string;
}

function isType<T extends Action>(
    action: Action,
    type: ActionType<T>
): action is T
function isType<T extends Action>(
    action: Action,
    type: string
): action is T
{
    return action.type === type;
}

const FOO: ActionType<FooAction> = 'FOO';
const BAR: ActionType<BarAction> = 'BAR';
const BAZ = 'BAZ';

export const reducer = (state: any, action: Action): any => {
    if (isType(action, FOO)) {

        let foo = action.foo;
    }
    if (isType(action, BAR)) {

        let bar = action.bar;
        action.foo; // error
    }
    if (isType(action, BAZ)) {
        action.type; // BAZ
        action.foo; // error
    }
    return state;
}
@aikoven
Member
aikoven commented Mar 3, 2016

@jonaskello

Btw, how do you handle actions that have no payload? Just setting the payload type to any does not seem like a nice solution...

I'm using void type parameter for them, although until this is resolved I have to make payload argument of resulting action creator optional:

interface ActionCreator<P> {
  type: string;
  (payload?: P): Action<P>;  // payload is optional
}

const fooAction = actionCreator<void>('FOO_ACTION');

const action = fooAction();

However, this is obviously not so type-safe. Another option would be to pass void 0 as a parameter, but I chose brevity over safety here, because I have lots of actions with no payload.

@use-strict
Contributor

@igorbt , elegant solution. Gets rid of the nominal typing hack, the unneeded classes and the risk of being tempted to use instanceof.

As an idea, it could be possible to use the same name for the ActionType constant as for the action interface without them conflicting. The constant and interface will probably be defined in the same place if the app is modular.

I'm also thinking if it might be possible somehow in the future to replace strings with Symbols. As it stands, we need to generate some unique strings for each action type, while still having something human readable.

@jonaskello

@aikoven Thanks, doing it the same way now seeing the proposal for ignoring void is approved

@igorbt @use-strict I started out using the solution @geon proposed but it has the limitation that the action string no longer can be treated as a string. Eg. this code will not work:

const FOO: ActionType<FooAction> = 'FOO';
const myString:string = FOO;

The problem lies in that in JS there is a difference between String and string an TS can only extend String not string. This will become a problem if you want to send your action.type string to another API such as redux-saga. Becuase that API will expect a string but you will be sending a String. Of course you can type-cast in those cases but in the end I went with the solution @aikoven proposed because it avoids this problem and also it saves one line of code for each action since the string is baked into the action creator function itself.

@jonaskello

@use-strict Usage of Symbol for actions has been discussed many times and it is not a good idea because Symbolis not serializable. So if you later want to send your actions somewhere else (local storeage, http etc.) it will not work. So we should use strings. The solution @aikoven suggested will work almost the same way as Symbols anyway. You refer to the action creator function everywhere instead of the string itself. So the action creator function itself becomes the symbol. If you want, you could modify the actionCreator() function in his solution to generate unique strings automatically since you will never refer to the strings directly in the code.

@jonaskello

One part of @aikoven solution I had a hard time understanding was this:

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

To clarify: It looks like this type specifies an object but it actually specifies a single function. He is using the trick that functions in JS are objects. So you can attach a property to a function. So this type specifies a function of type (payload: P) => Action<P> but it also says that this function can have an attached property called "type". I find this TS syntax to be quite funky but it works.

@jonaskello

Actually I think the idea of attaching the string to the action creator function has merit outside TS. Here is a plain ES6 example of doing the same thing which may be easier to understand (not tested it but I think it works :-)). Notice that we no longer need to declare a separate string constant because the string is baked into the action creator function:

// In actions.js
export function fooAction(foo:string) {
    return {
        foo: foo;
    }
}
fooAction.type = 'FOO';

// In reducer.js
import {fooAction} from './actions';

function(state, action) {
    if(action.type === fooAction.type) {
        // Do foo action stuff
    }
}

Maybe this could be added as a reducing boilerplate recipe in the docs.

@igorbt
igorbt commented Mar 4, 2016

@aikoven @jonaskello I like your solution more and more. It took me some time to understand it's advantages but now I will give it a try. I'll also switch to FSA.
@jonaskello i think in your ES6 example you should also use actionCreator factory otherwise it will not work.

@Cooke
Cooke commented Apr 24, 2016

After playing with the great suggestions here (mainly @jonaskello's) I found two small drawbacks that I wanted to overcome:

  • It is a bit awkward to use action creators in the reducers. Feels strange with the camel-casing, no type highlighting and just having them there in general.
  • The need for a action creator in the first place. I have a mostly server driven application where many actions are instantiated on the server and the type definitions are generated.

Here is my suggestion:

export class ConnectAction {
    static type = "Connect";
    host: string;
}

// Variant1: less boilerplate to create
export const connect1 = common.createAction(ConnectAction)

// Variatn2: less boilerplate to call
export const connect2 = common.createActionFunc(ConnectAction, (host: string) => ({ host: host}))

Invoking action creators:

connect1({ host: 'localhost'}) 
connect2('localhost');

Reducer:

const reducer = (state: any = {}, action: Action) => {
    if (is(action, ConnectAction)) {
        console.log('We are currently connecting to: ' + action.host);
        return state;
    } else {
        return state;
    }
}

The common/generic stuff:

export interface ActionClass<TAction extends Action> {
    type: string;
    new (): TAction;
}

export interface Action {
    type?: string;
}

export function createAction<TAction extends Action>(actionClass: ActionClass<TAction>): (payload: TAction) => TAction {
    return (arg: TAction) => {
        arg.type = actionClass.type;
        return arg;
    };
}

export function createActionFunc<TAction extends Action, TFactory extends (...args: any[]) => TAction>(
    actionClass: ActionClass<TAction>,
    actionFactory: TFactory): TFactory {
    return <any>((...args: any[]): any => {
        var action = actionFactory(args);
        action.type = actionClass.type;
        return action;
    });
}

export function is<P extends Action>(action: Action, actionClass: ActionClass<P>): action is P {
    return action.type === actionClass.type;
}

The drawbacks with this solution are:

  • Unconventional usage of classes - they are never instantiated.
  • Have to make the type property i the Actions interface optional (this is super minor to be).
  • It is cumbersome to add optional properties to the actions since they are classes (have to add an additional interface).

Even though the new drawbacks this solution works better for my current use cases. Also I don't use the Flux Standard Action style but I bet one can make such a variant with my suggestion.

@gaearon
Member
gaearon commented Apr 24, 2016

Note that we have added some typing files in 3.5.x.
If those arenโ€™t as helpful feel free to raise an issue to discuss how they can be improved.

@kristian-puccio kristian-puccio referenced this issue in Microsoft/TypeScript May 16, 2016
Merged

Control flow based type analysis #8010

@aikoven
Member
aikoven commented May 23, 2016

@Cooke see #992 (comment). I don't see anything wrong with having action creators in reducers because they only encapsulate action type string and payload shape. Of course there's also action instantiation logic, but there's no difference with classes: they too contain its own type info along with construction logic.

@sharkBiscuit

@geon In regards to the "String vs. string" problem", how about:

export interface _ActionType<T>{}
export type ActionType<T> = string & _ActionType<T>

// important to use _ActionType here
export function isAction<T extends Action>(action: Action, type: _ActionType<T>): action is T;
export function isAction<T extends Action>(action: Action, type: string): action is T {
    return action.type === type
}

You can use this as before but it will be a regular string and not a String.

@mweels
mweels commented Jun 28, 2016

This approach is working good...

  1. Get typed actions and payload to dispatch.
  2. Redux gets clean plain objects
  3. Reducers gets typed action types, and typed payloads.

First setup enums..

export enum ActionTypes { TYPE_SELECTED }

create your commands...

export class ActionSelectedCommand  {
  constructor(public actionitem: any) {
  }
}

Create your function to call when dispatching (notice I am creating a standard object that in a sense serializes my command object).

So every action will always have a type and a payload.

export function ActionSelected(actionItem): any {
  return {
    type: ActionTypes.TYPE_SELECTED,
    payload: JSON.stringify(new ActionSelectedCommand(actionItem))
  };
}

Then on your reducer...


  if (!state)
    state = new NewStore();

  let type: actions.ActionTypes = <actions.ActionTypes>action.type;  -- constant action type :)

  switch (type) {
    case actions.ActionTypes.TYPE_SELECTED: {  -- more constact action type
      var warmAndFuzzyPayLoad = <actions.ActionSelectedCommand>JSON.parse(action.payload)
      -- Then you deserialize back to your action command with all your parameters type set.
      return state;
    }
    default:
      return state;
  }

Then dispatch away...

store.dispatch(actions.ActionSelected("Tight Actions"));

So far this seems to be working well.

It forces to keep the action commands very simple because any recursion or self referencing will blow this up.

I like the idea of keeping the redux store type free. This allows the ability to have flexibility in and out of the bus.

Cheers

@frankwallis

I think that discriminated union types which are arriving in TypeScript 2.0 should be a good solution for this issue.

@dennari dennari referenced this issue in lucified/minard-backend Aug 9, 2016
Merged

Event bus #31

@huiwang
huiwang commented Aug 25, 2016

My dream action support would be something like elm or scala.js where reducer is powered by pattern matching. But it seems typescript would not have pattern matching in the near future :(

@vinga
vinga commented Sep 17, 2016 edited

Another version with plain objects in reducer, based mainly on @Cooke.
It allows to inline action payload fields and omit explicit Action definition.
ActionTypes here are used as generic action creators.

export interface Action {
    type?: string;
}
export class ActionType<T extends Action>{
    type: string;
    constructor(type: string) {
        this.type=type;
    }
    new(t: T):T {
        t.type=this.type;
        return t;
    }
}

export function isAction<T extends Action>(action: Action, type: ActionType<T>|string): action is T {
    if (typeof type === 'string') {
        return action.type==type;
    } else if (type instanceof ActionType) {
        return action.type === type.type;
    }
    return false;
}

// here come action creators
const FOO: ActionType<{foo: string, foo2: string}> = new ActionType<any>('FOO');
interface BarAction extends Action {
    bar: string;
}
const BAR: ActionType<BarAction> = new ActionType<BarAction>('BAR');

in reducer:

export default function reducer(state = [], action:Action) {
    if (isAction(action, FOO)) {
        action.foo // have access
        return state;
    } else if (isAction(action, BAR)) {
        action.bar;
        return state;
    } else
        return state;
}

create new action like this

FOO.new({foo: 'foo'}) // error, foo2 is required
FOO.new({foo: 'foo', foo2: 'foo'}) // OK

// dispatch it like this
dispatch(FOO.new({foo: 'foo', foo2: 'foo'}));

You have type safe payload and don't have to use designated task creator.
You still can create actions old way. Main drawback is that type is not required in Action interface.

@amadeogallardo
amadeogallardo commented Sep 24, 2016 edited

Here's a new version based on the proposals by @aikoven, @jonaskello and inspired on redux-actions.
The goal is to to address these specific scenarios:

  1. Type safety for state, actions and payload
  2. Allow sharing of type safe state and local variables for all handlers inside reducer functions
  3. Preserve standard action creator function syntax to allow for logic, thunks, etc.
  4. Reduce switch-case boilerplate in reducers

Here it goes, let me know if you see any major flaws!

[EDIT: Refactored and renamed the HandleActions function as per @alexfoxgill's comments

// FSA Compliant action
interface Action<P> {
    type: string,
    payload?: P,
    error?: boolean,
    meta?: any
}

interface Todo {
    text: string
}

type HandlerMap<T> = {
    [type: string]: { (action: Action<any>): T }
}

// Handle actions "inspired" by redux-actions
// Only pass the action parameter to handlers, allowing to share type-safe state in the reducer
export const DEFAULT = "DEFAULT";
    export function handleAction<T>(
    action: Action<any>,
    handlers: HandlerMap<T>,
    defaultState: T): T {
    // As improved by @alexfoxgill
    const handler = handlers[action.type] || handlers[DEFAULT];
    // Execute the function if exists passing the action and return its value.
    // Otherwise return the Default state. 
    return handler ? handler(action) : defaultState;
}

    // If no handlers matched the type, execute the DEFAULT handler 
    if (handlers[DEFAULT]) {
        return handlers[DEFAULT](action);
    }

    // If DEFAULT not declared, return the state from the parameter
    return defaultState;
}

export const ADD_TODO = "ADD_TODO";
// Declare the type for the payload
export type ADD_TODO = Action<string>
// Standard action creator pattern with type safe return value
export const addTodo = (text: string): ADD_TODO => {
    // Allows for some complex logic, async behavior, thunks, etc.

    return {
        type: ADD_TODO,
        payload: text,
        meta: {
            foo: "Bar"
        }
    }
}

export const DELETE_TODO = "DELETE_TODO";
export type DELETE_TODO = Action<number>
export const deleteTodo = (index: number): DELETE_TODO => {
    // Some complex logic, async behavior, thunks, etc.

    return {
        type: DELETE_TODO,
        payload: index,
    }
}

// Standard pattern for creating reducers
// Type safety for state and return value, shared for all handlers
export const todoReducer = function (
    state: Todo[] = [],
    action: Action<any>): Todo[] {
    // Local variables can be declared and shared by all handlers
    let foo = action.meta && action.meta.foo;

    // Type safety for return value of state
    return handleActions<Todo[]>(action, {
        // Less boilerplate by removing the 'return' and 'break' statements
        // Payload is a string 
        [ADD_TODO]: ({payload}: ADD_TODO) => [...state, { text: payload }],
        // Payload is a number
        [DELETE_TODO]: ({payload}: DELETE_TODO) =>
            [...state.slice(0, payload), ...state.slice(payload + 1)],
        // For more complex actions, expand function
        [DEFAULT]: ({payload}) => {
            // Can handle other operations that don't depend on type
            if (foo) {
                console.log(foo);
            };
            return state;
        }
    },
        // In case DEFAULT is not implemented, return the default state 
        state);
}

// Dispatch in the context of a connected component
addTodo("Foo");
deleteTodo(0);
@alexfoxgill
alexfoxgill commented Sep 30, 2016 edited

@Agalla81 I like this design. I'm confused about the handleActions implementation though - what's the loop for? Wouldn't the following work:

type HandlerMap<T> = {
    [type: string]: { (action: Action<any>): T }
}

export function handleAction<T>(action: Action<any>, handlers: HandlerMap<T>, defaultState: T): T {    
    const handler = handlers[action.type] || handlers[DEFAULT];
    return handler ? handler(action) : defaultState;
}

You can also define a helper function for this pattern like so:

type HandlerMapWithState<T> = {
    [type: string]: { (action: Action<any>): (state: T) => T }
}

export function createReducer<T>(defaultState: T, handlers: HandlerMapWithState<T>) {
    return (state: T = defaultState, action: Action<any>): T => {
        const handler = handlers[action.type] || handlers[DEFAULT];
        return handler ? handler(action)(state) : state;
    };
}

Which lets you create a reducer with this syntax:

const reducer = createReducer(0, {
    INCREMENT: (a: INCREMENT) => s => s + a.payload.incBy,
    DECREMENT: (a: DECREMENT) => s => s - a.payload.decBy
});
@amadeogallardo

@alexfoxgill You're correct, the loop was unnecessary. This has been updated based on your suggestion for the handleAction implementation, thanks for noticing.

I like the createReducer approach but it may collide with one of the items that I wanted to address:
"2. Allow sharing of type safe state and local variables for all handlers inside reducer functions".

  • Type Safe state is indeed shared with the "double" arrow function "=> s =>".
  • But how can shared local variables be defined when using createReducer?
    In some scenarios, I find it useful to define those if there's common logic that can be re-used by multiple action handlers inside the reducer.
@alexfoxgill

@Agalla81 I guess that's a fair point, though I've not encountered a time when I've needed that common logic within the reducer definition itself (normally it's in other functions)

@amadeogallardo

@alexfoxgill here's an example from Redux's documentation that shows such a case scenario, by declaring a "post" local variable that can be reused in different action handlers.
Source: http://redux.js.org/docs/recipes/reducers/UpdatingNormalizedData.html

// reducers/posts.js
function addComment(state, action) {
    const {payload} = action;
    const {postId, commentId} = payload;

    // Look up the correct post, to simplify the rest of the code
    const post = state[postId];

    return {
        ...state,
        // Update our Post object with a new "comments" array
        [postId] : {
             ...post,
             comments : post.comments.concat(commentId)             
        }
    };
}
@aikoven
Member
aikoven commented Oct 11, 2016

Just published a package implementing my above approach:
https://github.com/aikoven/redux-typescript-actions

@awesson
awesson commented Jan 22, 2017 edited

I stumbled upon this thread when trying to use concrete classes for actions like @nickknw to enforce type safety and redux reasonably yelled at me for not using plain objects.

I realized that with with Typescript 2.0, @frankwallis' premonition has come true, so I figured I would update with an example for anyone else who ends up here.

/* define the waxing actions and create a type alias which can be any of them */
interface WaxOn
{
    type: "WaxOn";
    id: number;
    waxBrand: string;
}

function createWaxOnAction(id: number, waxBrand: string): WaxOn
{
    return { type: "WaxOn", id, waxBrand };
}

interface WaxOff
{
    type: "WaxOff";
    id: number;
    spongeBrand: string;
}

function createWaxOffAction(id: number, spongeBrand: string): WaxOff
{
    return { type: "WaxOff", id, spongeBrand };
}

type AnyWaxingAction = WaxOn | WaxOff;

/* define the dodging actions and create a type alias which can be any of them */
interface Bob
{
    type: "Bob";
    id: number;
    bobDirection: number[];
}

function createBobAction(id: number, bobDirection: number[]): Bob
{
    return { type: "Bob", id, bobDirection };
}

interface Weave
{
    type: "Weave";
    id: number;
    weaveDirection: number[];
}

function createWeaveAction(id: number, weaveDirection: number[]): Weave
{
    return { type: "Weave", id, weaveDirection };
}

type AnyDodgeAction = Bob| Weave;

type AnyActionAtAll = AnyWaxingAction | AnyDodgeAction;
// action must implement one of the Action interfaces or tsc will yell at you
const reducer = (state: State = {}, action: AnyActionAtAll) =>
{
    switch(action.type)
    {
        case "WaxOn":
            // Will only let you access id and waxBrand
        case "WaxOff":
            // Will only let you access id and spongeBrand
        case "Bob":
            // Will only let you access id and bobDirection
        case "Weave":
            // Will only let you access id and weaveDirection
        default:
            // Make sure there aren't any actions not being handled here
            // that are included in the union
            const _exhaustiveCheck: never = action;
            return state;
    }
}

If you accidentally name two actions with the same type string, typescript wont necessarily yell at you, but in the switch-case for that string, typescript will only let you access the members which both interfaces have in common.

@jonaskello

@awesson You might want to assert that action is of type never in the default case in the switch statement. This way you can be sure that all actions are handled and you don't forget to add more cases to the switch if you create new actions. See this page under the "Exhaustive Checks" section for more info.

@awesson
awesson commented Jan 22, 2017

Thanks @jonaskello. That's a good point.

I updated my example, but really it's probably better to just look at the example you linked. (I don't know how I missed that.)

One note about the exhaustive check is that if you are just starting to write actions for a new module following this organization and you only have 1 action so far, the type alias wont be a union and so the exhaustive check will always fail.

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