-
-
Notifications
You must be signed in to change notification settings - Fork 15.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for typed actions (Typescript) #992
Comments
Does TypeScript let you type-check shapes of plain objects instead of defining classes? Wouldn't TypeScript interfaces be enough to strongly type plain object actions? |
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. |
@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 |
Yes but then if you use 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. |
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 |
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. |
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. |
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 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 #992 (comment), I find casting to be the best solution we can offer. You can also publish |
Finally: for the record, I'm not hatin' on classes. 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. |
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.
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.
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).
100% agree with you here
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. |
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. |
This would involve user implementing functions that map |
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.
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 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. |
I don't want to change it in core for reasons above. As I said previously, please feel free to create |
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 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 ...
}
} |
@Matthias247 that's exactly what I need, thanks! |
@Matthias247 That's fantastic, thank you for pointing that out! Did not know about that feature, that is exactly what I need :) |
I'm using FSA and I found it very convenient to have generic 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 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
// ...
}
}); |
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:
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:
BTW, I didn't use NominalType as it seemed to add to the boilerplate, and just used directly void type. |
For those interested in a 'flux' lib with native support for typescript, you may want to check: https://github.com/AlexGalays/fluxx#typescript-store |
@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 #992 (comment) answers my question. Then why UPD2 By looking more deeply, the only addition to Redux is the |
@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. |
FWIW, from http://redux.js.org/docs/recipes/ReducingBoilerplate.html:
|
One interesting competing project that is TypeScript friendly is https://github.com/ngrx/store |
@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 |
After playing with the great suggestions here (mainly @jonaskello's) I found two small drawbacks that I wanted to overcome:
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:
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. |
Note that we have added some typing files in 3.5.x. |
@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. |
@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. |
This approach is working good...
First setup enums..
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.
Then on your reducer...
Then dispatch away...
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 |
I think that discriminated union types which are arriving in TypeScript 2.0 should be a good solution for this issue. |
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 :( |
Another version with plain objects in reducer, based mainly on @Cooke.
in reducer:
create new action like this
You have type safe payload and don't have to use designated task creator. |
Here's a new version based on the proposals by @aikoven, @jonaskello and inspired on redux-actions.
Here it goes, let me know if you see any major flaws! [EDIT: Refactored and renamed the HandleActions function as per @alexfoxgill's comments
|
@Agalla81 I like this design. I'm confused about the 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
}); |
@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:
|
@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) |
@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.
|
Just published a package implementing my above approach: |
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. |
@awesson You might want to assert that action is of type |
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. |
I have been looking at different solutions for full typing support (state and actions), and I have found https://gist.github.com/japsu/d33f2b210f41de0e24ae47cf8db6a5df + @awesson's solution to be a really good combination |
After playing around with the different alternatives I settled with the following approach https://github.com/Cooke/redux-ts-simple |
You can use union types but you can also use a big typed action object. // Before
{
type: "UserLoaded",
payload: {
user: { id: 4 }
}
}
// After
{
type: "UserLoaded",
userLoaded: {
user: { id: 4 }
}
} In the first case, there are lots of different action objects so you need a union type. interface UserLoaded {
type: "UserLoaded", // redundant
payload: {
user: User;
};
}
type MyAction = UserLoaded | OtherAction In the second case, there is just one action type with lots of payload keys. interface UserLoaded {
user: User;
}
interface MyAction {
type: string;
userLoaded?: UserLoaded;
otherAction?: OtherAction;
} You can reduce by matching the function userById(state = {}, action: MyAction) {
if (action.type == "UserLoaded") {
// action.userLoaded.user
}
} You can also check presence of payload key. function userById(state = {}, action: MyAction) {
if (action.userLoaded) {
// action.userLoaded.user
}
} If you use presence, you don't rely on enum Field {
Title,
Body,
}
interface TextChange {
field: Field;
newText: string;
}
interface Submit {
}
interface NewPostForm {
pageId: string;
textChange?: TextChange;
submit?: Submit;
}
interface MyAction {
type: string;
newPostForm?: NewPostForm;
} If you really love union-ing actions from sub modules you can still use interface inheritance. interface UserLoaded {
user: User;
}
interface UserAction {
userLoaded?: UserLoaded;
}
interface MyAction extends UserAction {
type: string;
} So yeah, it plays well with JSON serialization, existing redux tooling, and it's a nice balance between encapsulating some parts while still allowing cross-cutting actions. |
I guess everyone has already mentioned most of the solutions. My two cents on this topic, I just wanna emphasize about @aikoven approach. Even though I found this topic just now, the approach of my lib is very close to his. The only difference of my approach compare to @aikoven is we keep track of our actions by their Here how I've done it, I tried to remove all the boilerplate as much as possible: feature-x.actions.ts import { defineAction, defineSymbolAction } from 'redux-typed-actions';
import { ItemX } from './feature-x.types';
// For this action we don't have any payload
export const FeatureXLoadAction = defineAction('[Feature X] Load');
// Let's have a payload, this action will carry a payload with an array of ItemX type
export const FeatureXLoadSuccessAction = defineAction<ItemX[]>('[Feature X] Load Success');
// Let's have a symbol action
export const FeatureXDummySymbolAction = defineSymbolAction<ItemX[]>('[Feature X] Dummy Started'); feature-x.component.ts import { ItemX } from './feature-x.types';
import { FeatureXLoadAction, FeatureXLoadSuccessAction } from '../feature-x.actions';
...
store.dispatch(FeatureXLoadAction.get());
// or in epics or side effects
const payload: ItemX[] = [{ title: 'item 1' }, { title: 'item 2' }];
store.dispatch(FeatureXLoadSuccessAction.get(payload));
// And a more strict check for payload param
store.dispatch(FeatureXLoadSuccessAction.strictGet(payload)); feature-x.reducers.ts import { PlainAction } from 'redux-typed-actions';
import { ItemX } from './feature-x.types';
import { FeatureXLoadAction, FeatureXLoadSuccessAction } from '../feature-x.actions';
export interface ItemXState {
items: ItemX[];
loading: boolean;
}
...
export function reducer(state: ItemXState = InitialState, action: PlainAction): ItemXState {
if (FeatureXLoadAction.is(action)) { // Visually helps developer to keep track of actions
// Within this branch our action variable has the right typings
return {
...state,
loading: true,
}
} else if (FeatureXLoadSuccessAction.is(action)) {
return {
...state,
loading: false,
items: action.payload, // <- Here we are checking types strongly :)
}
} else {
return state;
}
} feature-x.epics.ts (redux-observable related) ...
class epics {
loadEpic(): Epic<PlainAction, AppState> {
return (action$, _store) => action$
.ofType(FeatureXLoadAction.type)
.switchMap(() =>
this.service.getRepositories()
.map(repos => StoreService.transformToItem(repos))
.delay(2000)
.map(repos => FeatureXLoadSuccessAction.strictGet(repos))
.catch((error) => Observable.of(FeatureXLoadFailedAction.strictGet(`Oops something went wrong:\n\r${error._body}`))));
} |
@jonaskello i liked your comment about symbols. You mention: So i think what @aikoven propose to don't have this problem is not that bad.
|
@nasreddineskandrani Or a better solution would be to check the |
@aminpaks i think devtool with real action name in prod is useful. |
@nasreddineskandrani I never said we should remove the initial action type. I meant let's add a hash to the end so we'll have an unique action. |
one of the dev in this thread mention this: |
I recently added a React example using redux-typed-actions to demonstrate how this strategy can help to improve productivity and will remove all boilerplates. Take a look here. |
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!
The text was updated successfully, but these errors were encountered: