Article summary
React apps with Redux are built around an immutable state model. Your entire application state is one immutable data structure stored in a single variable. Changes in your application are not made by mutating fields on model objects or controllers, but by deriving new versions of your application state when you make a single change to the previous version.
This approach comes with some huge benefits in tooling and simplicity, but it requires some care.
It’s certainly possible to model your Redux application state using plain old JavaScript/TypeScript objects and arrays, but this approach has a few downsides:
- The built-in data types aren’t immutable. There’s no real protection against an errant mutation of your application state, violating the basic assumptions of Redux.
- Creating copies of arrays and maps is inefficient, requiring a
O(n)
copy of the data.
Fortunately, Facebook has also released Immutable.js, a library of persistent immutable data structures. This library provides maps, sequences, and record types that are structured to enforce immutability efficiently. Immutable.js data structures can’t be changed–instead, each mutation creates a brand new data structure that shares most of its structure with the previous version.
Since it’s immutable, this is safe. It’s also efficient. By using fancy data structures, each change is O(log32 n)
, effectively constant time.
Immutable.js comes with type definitions for TypeScript’s built-in data structures. Less fortunately, creating immutable record types (e.g., classes) with heterogeneous values isn’t as straightforward as Map<K,V>
.
I looked for a solution and found a few libraries, but they all were too complicated. I wanted something that:
- Is simple and easy to understand
- Provides typed access to properties
- Offers a type-safe, statically enforced way to perform updates
- Resists incorrect use, without requiring static enforcement
Example
Here’s an example of the simplest approach I could come up with:
type CounterParams = {
value?: number,
status?: Status,
};
export class Counter extends Record({ value: 0, status: OK }) {
value: number;
status: Status;
constructor(params?: CounterParams) {
params ? super(params) : super();
}
with(values: CounterParams) {
return this.merge(values) as this;
}
}
(Assume Status
is an enumeration type with a value called OK
.)
This approach supports:
- Creation via a strongly typed constructor with defaults for omitted values
- Statically-typed updates using the
with
method - Statically-typed property access
Immutable.js includes a bunch of other methods, such as merge,
that are inherited from Record
or Map
and present but untyped. I don’t see these as part of the usual API for my records, and that’s OK with me. I may not get a static error if I try to set a property, but I’ll get a runtime error. That’s safe enough for me, given that the convention will be to use the with
method.
Use
Here’s how you’d use one of these immutable records:
let c1 = new Counter({ value: 2 });
c1.value // => 2
c1.status // => OK
c1.foo // Type error
let c2 = c1.with({ value: 3});
c2.value // => 3
c1.with({ status: 'bogus' }) // Type error
How It Works
All of the actual functionality is inherited from the Immutable.js Record
class. We’re using the type system to create a simple typed facade. There are a few key steps.
1. Define a parameter type
First, define a type that contains all of the fields you’ll have in your record. Each one is optional:
type CounterParams = {
value?: number,
status?: Status,
};
Since Counter
will have a value
and a status
, our CounterParams
type has optional entries for both of those. This is an object type that may have up to a value
of type number
and a status
of type Status
, but it may be missing either or both of those.
This would be the logical type of the Immutable.js constructor argument and update
method, if we’d written it from scratch. Since we’re inheriting the implementation from JavaScript, it doesn’t have more detailed type information.
2. Inherit from Record
Next, we define our class and inherit from Record
:
export class Counter extends Record({ value: 0, status: OK }) {
There’s nothing special here. This is just straight-up, normal, untyped Immutable.js record type definition with zero type help. We’re just providing default values for value
and status
to the Record
class creation function.
3. Type the constructor
Now, we define our constructor to take an optional CounterParams
argument and delegate to super. If we construct our object with no argument, it gets all default values. If we construct it with params, we end up overriding just the supplied arguments:
constructor(params?: CounterParams) {
params ? super(params) : super();
}
Note that we don’t call super
with undefined to get the proper default behavior.
4. Define with
Our with
method is just a typed alias for the merge
method inherited from Immutable’s Map
type. Unfortunately, merge
won’t statically enforce our type signature for parameters, and is typed to return a Map
. We use CounterParams
as our type argument to solve the former issue. We cast as this
to solve the latter, which works because merge
does, in fact, return an instance of our type–it just doesn’t specify that in its signature at the time of writing this. Using as this
convinces TypeScript that with
will, in fact, return a Counter
.
with(values: CounterParams) {
return this.merge(values) as this;
}
And that’s it! A little repetitive, but simple and easy to understand. And we now have nice, statically typed persistent immutable model types.
Awesome post, the 4th piece is exactly what I was missing.
One addition: you can now get the static error on assignment by marking your properties as readonly on your class.
I just created a little library meant to do structural-sharing tree updates, with full static analysis and completion.
It has the advantage to use Plain-Old Javascript Objects, compared to ImmutableJS Records that wrap your object and make it more difficult to handle, and do not provide great static analysis.
You can use it very easily with Redux, and we use it everyday on our projects.
https://github.com/kube/monolite
Nice post! You might even get more typing assistance by doing this:
“`
export interface CParams {
value: number;
value2: number;
}
export class C extends Record({ value: 1, value2: 2 } as CParams) implements CParams {
readonly value: number;
readonly value2: number;
constructor(params?: Partial) {
params ? super(params) : super();
}
with(vals: Partial): C {
return this.merge(vals) as this;
}
}
“`
I think you can accomplish the same objectives without any libraries.
interface State {
readonly a: string,
readonly b: string
}
const astate: State = { a: “high”, b: “low”}
astate.b = “lower” // compile error
const another = { …astate, b: “lower”}
Am I missing anything?
Good point Jeremy! This post was released before spread syntax was added in TS 2.1. Using that combined with readonly properties is probably a better default at this point.
Hi,
You might want to read immutablejs documentations. It maps unchanged properties of former object and only assigns changed properties. So it is fast and space saving. But of course it could be cumbersome for simple solutions and your proposal works just fine.
It seems to me that this approach provides compile-time safety, but it doesn’t give any run-time safety. For runtime safety, I think you need something like Immutable.js.
I appreciate the article Drew. I arrived here looking for an answer to this question – perhaps you could help:
I have a class extending a Record like yours above. But in the constructor, I also do some validation of an array (say…. do all the numbers in it add up to 100?). If not I also add a property `isValid` (a boolean).
Now, when I want to update the object, I also want to rerun that validation logic. My first thought was to create a class from the update, but then I get all those default values, which will overwrite existing values (which I do not intend, I just have a couple fields to update).
A second thought is to add it to your `with` function??
What do you think?
Hi Morgan,
I think running the validation in
with
would work. I’d consider a few other options as well.One potential downside about the
isValid
approach is that it complicates your domain model with validation logic. It may be desirable depending on your circumstance, but my default would be to avoid it.One useful technique for this is to create a separate type
Valid<T>
which is aT
that has been proven to be valid. You can then define a type guard which, given some model, proves that that model is valid.You can then define containers and functions to take a
Valid<Model>
and typescript will enforce that anything stored there/passed into that function has been checked for validity.See an example here.
You can elaborate on this technique to return a set of validation errors in the case of invalid objects by changing validate model to build/return some sort of failed validation summary instead of undefined.
Drew
Interesting… thanks for the gist and explanation. I was unfamiliar with type guards. I’m trying to get through the rest of that gitbook.
Here’s another consideration for this scenario. I am hoping to save the `isValid` to the object in the DB. Why? I’d like to query against it perhaps. At least use it for user analytics.
With that in mind, I like your idea of returning (one of) a set of validation errors for invalid objects. Think you would do something like:
if (isInvalidForReason1(someModel)){
someModel.invalid = ‘REASON_1’;
}
Before sending the PUT/PATCH to the db that is.
Thanks for you thoughts on this.
-Morgan
The current rc release of immutable has much better TypeScript generic support:
npm install immutable@rc
import * as Immutable from “immutable”;
interface Foo {
id: number;
bar: string;
}
const FooRecord = Immutable.Record({
// Default values
id: null,
bar: “Empty (default)”
});
const foo = FooRecord({ id: 3 });
foo.id; // 3
foo.bar; // “Empty (default)”
const bar = foo.merge({ bar: “blah” });
bar.id // 3
bar.bar // “blah”