I love Redux. It’s such an elegant and simple concept that solves so many problems. But like every other solution, adding new concepts to your code base can also introduce the occasional headache.
One of these headaches is that Redux does not enforce a lot of it’s conventions, but when you don’t adhere to them, things start breaking further down the road in strange ways.
Today, I’m going refer to one of Redux‘ three principles: State is read-only
It states:
The only way to change the state is to emit an action, an object describing what happened.
The thing is, this is only convention. Usually, a Redux state is a plain JavaScript object. And those can be mutated at all times.
Imagine that you have a Redux store that contains an API state and one of these API items is passed to one of your components. In that component, you do a small change before rendering, modifying the item that you received from the store.
Well congratulations, you just changed your Redux store. And Redux didn’t even notice it. Of course, this is a bug, and one born from oversight that should never happen to a developer experienced in Redux.
But of course, such things happen.
Also, there’s no real guarantee that the developer touching that data in the end knows anything about Redux at all. It might just be someone from a different team, writing a „dumb“ React component that does not care where the data came from and does not assume that changing it could do any harm.
While you might be able to prevent this in your company by educating everyone on your team (and you should!), in an open source project you really can’t – and one dreading day, you might miss such an error in your PR review. Good luck finding that bug later. (The same thing could happen in a sloppily written reducer, or, honestly, just everywhere else in your code.)
So you get it, I really want you to prevent mutability in your state.
deep-freeze?
The easiest solution to the problem would be to use a library like deep-freeze and apply it to everything that comes out of our reducers. There are even multiple middlewares out there that automate that process for us.
But while this is a great idea in your tests (changing something immutable would trigger an error, thus failing your test), according to @dan_abramov, one of Redux’s co-authors, this will hurt your performance.
Also, this would still leave us with the default JavaScript APIs for object handling. And those are really made for modifying objects instead of returning modified copies (although the spread operator makes that stuff a lot more readable).
immutable.js to the rescue
So let’s go down a different road and take a look at immutable.js.
Immutable offers a set of data structures (most notably, Lists and Maps and specialized variants), that once they have been initialized can not be modified any more. At the same time, these Immutable objects offer a lot of modification methods (like set, update and merge) that return a modified copy of the original object. Additionally, they offer a number of chainable collection methods like filter
, sort
and map
like those you are already used to from JavaScript. (On a sidenote: if you are chaining those a lot, take a look at the documentation of Seq!)
Enter the TypeScript confusion
That’s all fine and dandy, and if you’re using pure JavaScript/ES6, you can just replace your plain-JavaScript Redux-State with an immutable Redux-State and propably do a lot of refactoring (you’ll have to use all that immutable object methods now, so you really should get invested in immutable rather sooner than later) and you’re done.
On the other hand, if you’re using TypeScript, you might be pulling your hair out by now. It seems like introducing immutable.js just robbed you of any option to correctly type your Redux store.
While before, you could have a state like this:
interface State { people: Array; } interface Person { name: string; height: number; eyeColor: string; }
You’re now down to:
interface State: { List; } type Person = Map;
So we lost a lot of type information here. Can’t we do better than that?
What about immutable Records?
Immutable offers a data type called „Records“. So let’s look at an example on how to use a record. (I’m gonna write out all types here for easier understanding – some of them are implicit)
interface Person { name: string; height: number; eyeColor: string; } const PersonRecordFactory: Record.Factory = Record({name: '', height: 0, eyeColor: 'brown'}); const user: Record = PersonRecordFactory({name: 'Chuck', height: 180});
So the first thing that catches the eye is that you can’t just create a record with some interface. You’ll have to create a Factory for that Record first, and you can only create such a Factory if you provide a default value for every property.
From that point on, though, you can use the Record with all the same methods as you would a Map, but with type safety.
So user.get('height', 0);
would implicitly be typed to a number.
Now we have reached our goal of immutability without losing type safety. Well, I’m still not happy with this solutions.
Let me tell you a few problems I have with this:
- required default values for all properties
Implicitly, this makes all of our record properties optional. Look at the example above. I could just omit thename
property when calling the Factory method and receive a validPerson
Record with a blank string as a name. In most cases, this is not what I really want – if I really want a person with a blank name, I want to specifiy so explicitly – and otherwise I want the compiler to tell me I forgot the name. - the getter call requires a default value as a second argument
While this is useful with plain JavaScript, in TypeScript we just can’t get to a situation where a property that we can read withget
has not been set. We just have this ugly & useless appendix of a second argument everywhere. - deserialization is clunky
If you are using more than justMap
andList
, serializing & deserializing by hand is not really fun asImmutable.toJS()
just serializes everything to Objects and Arrays. You can use remotedev-serialize for this purpose, and it works like a charm.
But when you want to deserialize Records, you have to pass all possible RecordFactories to the deserializer. Which means you have to collect every possible RecordFactory that you might have created somewhere in a central location. That’s far from elegant. - you can’t really use the benefits of Records in TypeScript
A central feature of Records is that they wrap property accessors. This means that you could calluser.name
instead ofuser.get('name', ‚‘);
. Unfortunately, there are no Typings for that, so you can’t use it in TypeScript.
Back to square one: typing Maps
So now that you know why we aren’t gonna use Records, although they might seem appealing, let’s get to my preferred solution.
Let’s use Maps, but let’s type them correctly. Take a look at this interface:
import {Map} from 'immutable'; export interface TypedMap extends Map{ toJS(): DataType; get(key: K, notSetValue?: DataType[K]): DataType[K]; set(key: K, value: DataType[K]): this; }
Right now, I’m only typing the get
, set
and toJS
methods. You can (and should) type more of them, but essentially that’s just copy & paste from the Record Interface Definition, so I’m going to leave that as an exercise to the reader.
Let’s take a look at how to use it:
const user = Map({name: 'Chuck', height: 180, eyeColor: 'brown'}) as TypedMap;
Not bad for a first attempt, but the cast doesn’t really look type-safe for now as we’re doing a cast without really guarding or validating anything. Let’s try again:
const createTypedMap = (data: DataType): TypedMap => Map(data) as any; const user: TypedMap = createTypedMap({name: 'Chuck', height: 180, eyeColor: 'brown'});
So by adding an intermediate createTypedMap
function, we now have a strong typing. There’s a cast to any, but everything around it is closely guarded, and outside of that method we don’t need to cast anything any more.
So let’s take a second look if we overcame the complaints we had for Records previously:
- required default values for all properties
There are no default values any more. If the interface requires a property, we have to provide it. - the getter call requires a default value as a second argument
The TypedMap datatype actually offers aget
method that only takes one argument. - deserialization is clunky
If you’re only using List and Map, you can just usetoJS
andfromJS
for serialization and deserialization. If you’re additionally usingOrderedMap
,Set
,OrderedSet
orStack
, you can stick to remotedev-serialize without collecting a ton of Factories. - you can’t really use the benefits of Records in TypeScript.
Well, we can’t use that with maps, but we really couldn’t use this at any given time. Also, with the usableget
method above, this hurts much less.
Bonus chapter: restricting types to immutable only
Everything up until now should give you every tool you need to use Redux with Immutable and TypeScript. But I’m going to give you a bonus here for the extra-paranoid:
Right now nothing hinders you from accidently introducing mutability back into the state like this:
interface PersonWithNicknames extends Person { nicknames: string[]; } const user: TypedMap = createTypedMap({name: 'Chuck', height: 180, eyeColor: 'brown', nicknames: ['Chucky']}); user.get('nicknames').push('Evil');
Here we created an Immutable TypedMap, but then added another nickname. Because the nicknames property is an array – and arrays are mutable.
Let’s do some typing voodoo:
import {Map, List} from 'immutable'; type AllowedValue = string | number | boolean | AllowedMap | AllowedList | TypedMap| undefined; interface AllowedList extends List {} interface AllowedMap extends Map {} export type MapTypeAllowedData = { [K in keyof DataType]: AllowedValue; }; export interface TypedMap > extends Map { toJS(): DataType; get (key: K, notSetValue?: DataType[K]): DataType[K]; set (key: K, value: DataType[K]): this; } const createTypedMap = >(data: DataType): TypedMap => Map(data) as any;
If we would try to create the TypedMap as we did above, we would get two compiler warnings:
TS2344:Type 'PersonWithNicknames' does not satisfy the constraint 'MapTypeAllowedData'. Types of property 'nicknames' are incompatible. Type 'string[]' is not assignable to type 'AllowedValue'. Type 'string[]' is not assignable to type 'TypedMap'. Property 'toJS' is missing in type 'string[]'. TS2345:Argument of type '{ name: "Chuck"; height: 180; eyeColor: "brown"; nicknames: string[]; }' is not assignable to parameter of type 'MapTypeAllowedData<{ name: string; height: number; eyeColor: string; nicknames: string[]; }>'. Types of property 'nicknames' are incompatible. Type 'string[]' is not assignable to type 'AllowedValue'. Type 'string[]' is not assignable to type 'TypedMap‘.
So let’s take a short look at those typings and what they are doing:
AllowedValue
defines our values that we will recursively allow in the data structure. Next to the basic types like string, number or boolean, we also allow for immutable Lists and Maps (you can extends this as required). Also, we allow the values to be other TypedMaps or undefined.- the interfaces
AllowedList
andAllowedMap
are required because TypeScript type definitions do not allow for circularity. - we force the
DataType
of TypedMap andcreateTypedMap
to extendMapTypeAllowedData
, which basically restricts the type ofDataType
keys to ourAllowedValues
.
And that’s it. If you apply all of the above, your Redux State should be bulletproof. Have fun!
Schreibe einen Kommentar