TypeScript, Redux, and immutable.js

Of Love and Hate: TypeScript, Redux and immutable.js

Avatar von Lenz Weber

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 the name property when calling the Factory method and receive a valid Person 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 with get 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 just Map and List, serializing & deserializing by hand is not really fun as Immutable.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 call user.name instead of user.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 a get method that only takes one argument.
  • deserialization is clunky
    If you’re only using List and Map, you can just use toJS and fromJS for serialization and deserialization. If you’re additionally using OrderedMap, Set, OrderedSet or Stack, 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 usable get 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 and AllowedMap are required because TypeScript type definitions do not allow for circularity.
  • we force the DataType of TypedMap and createTypedMap to extend MapTypeAllowedData, which basically restricts the type of DataType keys to our AllowedValues.

And that’s it. If you apply all of the above, your Redux State should be bulletproof. Have fun!

Avatar von Lenz Weber

Kommentare

15 Antworten zu „Of Love and Hate: TypeScript, Redux and immutable.js“

  1. Lesetipp: Of Love and Hate: TypeScript, Redux and immutable.js https://t.co/wDnTWS8qtQ https://t.co/pDkGArpXna

  2. I just published a blog post on #typescript, #redux and #immutable.js
    https://t.co/wBlRbZSsK0

  3. Of Love & Hate: #TypeScript, #Redux, and #immutableJS https://t.co/LVPMgvdJzd

  4. Of Love and Hate: TypeScript, Redux and immutable.js https://t.co/IrKfxitmWP via @mayflowergmbh

  5. Avatar von trolololo
    trolololo

    For the love of God simply skip ImmutableJS, unless you have REALLY large number of developers or when you have to tune performance (though constantly invoking .toJS() ain’t gonna help you with that). Totally unnecessary for typical apps.

  6. Avatar von Lenz Weber
    Lenz Weber

    Adding immutable at a point where you need to tune performance will hurt a lot, as you will have a lot of code that needs to be migrated at that point.
    I would rather use it sooner than later – but I agree with you, this should be a project-by-project decision that takes the type of data and type of operations on that data into account (immutable excels when you start to use Seq, for smaller data sets another great choice could be immer).

    As for the „constantly invoking .toJS()“: I never wrote you should do that. Usually you will have a call to .fromJS at state initialization, and then use those methods only at serialization and deserialization, which should generally be used with care and only when necessary, as (de)serialization always comes with a cost.

  7. #TypeScript & #Redux sind ein spannendes Konzept – unser @phry gibt Tipps für den richtigen Umgang: https://t.co/LVPMgvdJzd

  8. Ihr nutzt #TypeScript in Kombination mit #Redux? Unser @phry hat da ein paar Tipps: https://t.co/LVPMgvdJzd

  9. Our @phry loves #Redux. In this post he tells us something about redux states: https://t.co/LVPMgvvkXN

  10. Of Love and Hate: TypeScript, #Redux and immutable.js https://t.co/Kfi5Vo0XJ2 https://t.co/G0o1UaMVD1

  11. See this lib: https://github.com/engineforce/ImmutableAssign
    No penalty on reading, no scary syntax for reading, works perfect wit typescript both typechecked for reading and writing (meaning obtaining copy with changes :-) )

  12. Avatar von Martin Médéric
    Martin Médéric

    Hello,
    Great article thanks.
    Can you please help me to define the getIn overload with a structure like this :

    TypedMap<{
    isFetching: boolean;
    isFetched: boolean;
    list: TypedMap
    }>;

    I would like the getIn method propose me getIn([„list“][„etat“]), because actually I can write getIn([„list“][„isFetched“]) and Typescript compile. How do the validation control with recursively TypedMap ?

    Thanks a lot

  13. Avatar von Lenz Weber
    Lenz Weber

    I don’t believe this is currently possible to do generically. Typescript 3.0 made a big step into that direction and if they continue the language as I am assuming, in a few versions something like this might be possible:

    getIn([head, …tail]: [K, …X]):
    DataType[K] extends TypedMap ? ReturnType[‚getIn‘](…X)> : DataType[K]

    but at the moment, the tuple rest type `…X` I i used above is not possible yet.

    So at the moment, you can only explicitly type it out and overload it that way – I know this is not very desirable:

    getIn(any: [K]): DataType[K];
    getIn
    (any: [‚list‘, K]): DataType[K];

    (sorry, blog eats code… see this fiddle: https://codesandbox.io/s/myq02j83yj )

  14. Avatar von Médéric Martin
    Médéric Martin

    Thanks for your answer.

    One another thing, I declare in Props :
    timeline: TypedMap

    And after I would like to do this :
    timeline.map((object, i) => { …….. })
    But object type is TypedAllowed and not TestBean. Do you know how can I keep the DataType TestBean ?
    Thanks !

  15. Avatar von Marek Krzeminski
    Marek Krzeminski

    Can anyone help answer this question: https://stackoverflow.com/questions/52824312/how-to-use-immutablejs-map-with-typescript

    I can’t figure out how to define my immutable map that contains children that are also immutable map’s

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert


Für das Handling unseres Newsletters nutzen wir den Dienst HubSpot. Mehr Informationen, insbesondere auch zu Deinem Widerrufsrecht, kannst Du jederzeit unserer Datenschutzerklärung entnehmen.