TypeScript typings power, when used properly, could help us write better code. This article is about how to use TypeScript type system with Redux to create fully typed state management store.
I assume that you already know redux and maybe a little bit of TypeScript. I appreciate any inputs, I only tried implementing this recently and I am open to any improvements. This is a highly opinionated and subjective article.
I used to write Redux in JS, and then define its actions and types as constants. It is not unusual for me to see something like this.
The problem with the code above was, well, `AUTH_TYPES` can’t be changed as a whole, but this could happen.
Well, we can fix it by actually making those constants, constant.
Now we don’t have to worry about accidentally (or on purpose) changing the action type constants.
Now our reducer can refer to those constants on the switch cases, and everyone is happy!
Now imagine that we added another action type, but we forgot to handle it on some reducer that depends on it. Meh, don’t worry, we have someone that keeps the system running every 108 mins.
Even though the chance of forgetting to add an action type handler is low (I mean the moment you decided to add an action type is also the moment which you have to add its handler too), we will see how TypeScript can help us ensuring that all of the action type is handled properly.
I personally think that the amount of boilerplate that we have to write when using Redux is huge, and when maintaining huge boilerplate codes, we might end up with a lot of inconsistencies (multiple action types, reducer handling wrong action type). This is where TypeScript comes to the rescue (I am not saying that you would not be writing boilerplate code anymore though).
Redux, Meet TypeScript
Now, based on the code above, we can see that we have these problems:
- Action Type Constants is hard to maintain.
- We have to write a lot of action creator to wrap those action type constant.
- There might be inconsistencies.
- We might forgot to handle new action type on our reducers.
You might not encounter all of those problems, well, I only encountered number 1, 2, 3, and 4 (that’s all by the way).
Now, let’s try to solve those problems.
Problem #1 — Use TypeScript Types instead of Constants
Now, even though I am using TypeScript and Redux, I used to be writing the same code as above, on which I defined my action types as constants. Now, instead of doing that, I tried to throw away those types constants, and use TypeScript types instead.
We define the interface AuthStore to be our store schema, which mean that is how our store will looks like.
Below it is how we define the action types constants. We are using TypeScript interface to actually define how the action will looks like instead of just having the action type name as string constant.
Now you might wonder, what is PayloadedAction<”auth/set-token”> and Action<”auth/flush-token”>? Well, it is a simple interface that looks like this:
So, SetTokenAction would actually looks like this:
And FlushTokenAction is just an action without any payload.
The reason we are creating Action and PayloadedAction separately is so that we can have an action creator that is properly typed. You will read more about it below.
Now our constants is defined as a type, and we can refer to them as its type.
Wait, but we are writing the constants as a literal string there! What if we mistyped? Well, you can’t, TypeScript checks and ensure that only “auth/set-token” or “auth/flush-token” is written there.
If you are using Visual Studio Code, it will tell you what string constants it accepts there. Actual string constants as a constants!
Now we are sure, that the reducer will handle exactly types that we defined for TokenActions. The use of AuthStore[“token”] also ensure that the return type of the reducer matches our store schema.
Problem #2 — We Create Our Own Type Aware Action Creator
Now, we still need to define the action creator one by one, but at least we will create an action creator’s creator that is type aware and matches our original interface definition.
It means that createPayloadedAction should accept a payloaded action type definition, and the type and payload that it will create will match with the type definition provided.
The createAction is just the same, only without payload.
The setTokenAction above will become a function that looks like this:
And it is type aware, you will get code completion correctly.
If you don’t like defining an action creator, you can still dispatch an action that is type aware by providing its type when calling dispatch.
Problem #3 — Well, What We Did Above Solved This
When we are using TypeScript to check everything that we type, inconsistencies could be avoided.
Now everytime a reducer accepts a new action, we must define the type of the action, then we will have to update the reducer, and because TypeScript checks everything, we will have a very small chances of having inconsistencies.
If the type is not included as TokenActions, we will get an error, and if the return type doesn’t fit our schema, we will also get an error. Error during development is a bliss, error during production is a blast.
Problem #4 — Solved, TypeScript Checks Whether You Cover All Switch Cases or Not
No step is needed to be done here, the way we structured and typed our code helped us avoiding this problem.
TypeScript understands that inside the default case here, all action types were already handled, so if you tried to type “action” and then open code completion, it would not suggest you anything else. This way, you know that you handled all of the code cases (the type would become never).
Edit: Thanks to Matthew DeKrey for pointing this:
While your editor might tells you that the type of the action is now never, you can create an utility function to assert that anything passed to that function should be the type of never (which happen when you handled all of the cases), and it will actually results in an error if you missed some cases.
TypeScript will actually complains if you missed a case,
[ts] Argument of type 'FlushTokenAction' is not assignable to parameter of type 'never'.
It is possible to throw an error inside the assertNever as Matthew pointed out. However, because Redux will call all of the reducers no matter what action types it received and let the reducer decided whether they should react to the passed action or not, it will always be called thus resulting in an always error code.
Now there are some things that TypeScript didn’t check, example is when we have duplicate switch cases on our reducer:
However, we can also use TSLint to prevent that, with this option enabled:
TypeScript type system is powerful, we actually can remove those action types constants and those boring action creator boilerplate code with something that actually provide us with typed system that helped us ensuring our code is consistent.
The code used on this example can be viewed at https://github.com/adityapurwa/typescript-redux
Hope we all learned something from this article!