Immuto - Strongly Typed Redux Composition
What's good about Redux, I once asked, and I answered with a few things. Like React, it is one of those rare outbreaks of sanity that happen now and then. Read the docs, they're easy.
There's very little to the library (which is a good thing), because the main thing it implements is the store, which in its basic form is a very simple idea. I noted before how it says very little about composition patterns. I want ways of plugging reducers together, but with complete static type safety, so that it is not possible to dispatch the wrong kind of action, or an action whose data is not of the right type.
One composition feature is combineReducers
, which from a static typing perspective leaves us nowhere to go. Sometimes this happens because TypeScript is lacking some capability, but sometimes it's just because the library has done something undesirable and I think that's the case here, for reasons I will now go into at great length.
Reducers
A reducer is a function of the form (state, action) => state
. It makes a new state from an old one by interpreting an action, which is an instruction on how the state should be altered.
For simple examples it's no bother writing the reducer function by hand. Actions are discriminated by their type: string
property, which must be unique across all actions that can be handled by a given reducer. So in the tutorial we see the expected switch statement. And TypeScript 2.0 handles this very nicely; if you declare all your actions as interfaces, they become tagged union types and switch
becomes an adequate form of pattern matching.
But there's still quite a bit of boilerplate required. I think of reducers as being composed of individual reducers per action type. It should be possible to define an action as the combination of a name string and a function (state, payload) => state
in which payload is the data carried by the action. It should not be necessary to separately declare the action as an interface, and the act of composing several such action definitions should automatically produce a single reducer (state, action1 | action2) => state
, because it can take any of the specified action types and dispatch it accordingly.
To give us an example to drive the discussion, here's our first bit of state - completely immutable, of course, partly using List from immutable.js:
interface Book {
readonly title: string;
readonly price: number;
readonly authors: List<string>;
}
const empty: Book = {
title: "",
price: 0,
authors: List<string>()
};
Declaring actions
If you follow the Redux tutorial you'll see action creators being declared. These are functions, one per action type, each responsible for creating an instance of an action. These are usually gathered together in a file. Then in another file the reducer function is handwritten, typically as a switch statement, to handle each action type.
But really the action type and its interpretation go together. Suppose we could declare them like this:
export const setTitle = action("SET_TITLE",
(book: Book, title: string) => amend(book, { title }));
The amend
function is a slightly honed version of Object.assign
, to stand in for the cool { ...book, title }
syntax we're all looking forward to. A couple more actions:
export const setPrice = action("SET_PRICE",
(book: Book, price: number) => amend(book, { price }));
export const addAuthor = action("ADD_AUTHOR",
(book: Book, author: string) => amend(book, {
authors: book.authors.push(author)
}));
Having declared these pieces, defining the reducer ought to be as simple as:
const reduce = reducer(empty)
.action(setTitle)
.action(setPrice)
.action(addAuthor);
Here, reducer
is some helper that takes a "blank object" (serving as our preferred starting state for this type), and has an action
method that we can use to make an actual reducer function, capable of handling a single action. That reducer is already a function, but it also has its own action
method (functions can have methods, of course), allowing us to compose with another action, and so on. The end result is stored in reduce
and is the whole reducer function, strongly typed so it will only accept those three actions.
In TypeScript we can reuse a typename as a variable name, as they cannot clash. Indeed this is exactly how classes work. So I will gather all the above pieces in a namespace called Book
, to go with the type Book
. In abbreviated form:
interface Book {
...
}
namespace Book {
export const empty: Book = { ... };
export const setTitle = action( ... );
export const setPrice = action( ... );
export const addAuthor = action( ... );
export const reduce = reducer(empty)
.action(setTitle)
.action(setPrice)
.action(addAuthor);
}
So I can now do things to books like this:
const book1984 = Book.reduce(Book.empty, Book.setTitle("1984"));
That is, Book.setTitle
is an action creator in the usual sense, as well as having been woven into Book.reduce
. The latter is made possible by it having a couple of properties, type
of type "SET_TITLE"
and reduce
containing the function (Book, string) => Book
.
We're adopting the "payload" pattern, where an action only has type
and payload
properties, so:
export interface Action<T extends string, P> {
readonly type: T;
readonly payload: P;
}
The type of Book.reduce
is (state: Book, action: A) => Book
, where A
must come out as the union of three action types:
Action<"SET_TITLE", string> |
Action<"SET_PRICE", number> |
Action<"ADD_AUTHOR", string>
As well as the action
method that allows us to specify what actions the reducer can handle, a reducer might have further methods and properties. It could have a store
method, which wraps the standard Redux createStore
method purely so as to give it a stronger type definition:
const bookStore = Book.reduce.store();
bookStore.dispatch(Book.setPrice(3.99));
// Type error: bookStore can't accept Car actions
bookStore.dispatch(Car.changeGear(2));
The standard Redux Store
interface for dispatching actions is pretty loose, effectively:
interface Store<S> {
dispatch<A extends Action>(action: A): A;
// ...
}
So a store will accept any action, regardless of what the reducer can actually handle. Reducers are supposed to silently ignore actions they don't understand. I think of this as "action soup", by analogy with "tag soup" where browsers will take anything that looks vaguely like HTML and silently do their best. Where I'm building an app that I'm in control of, I want something a lot more locked down:
interface Store<S, A> {
dispatch<A1 extends A>(action: A1): A1;
// ...
}
That is, A
is the union type of all the supported actions, so A1
is whichever one we pass and hence what will be returned. By the way, the purpose of the return value of dispatch
, and even its type, is not actually completely specified - some discussion here. Redux middleware is treacherous territory. Here I'm just documenting the built-in behaviour.
Another handy member in our reducers is actionType: A
, which is a property that has the type of the union of all the support actions. Why is this useful? Because we've never actually declared that type manually ourselves; it was built for us. We might want to use it to declare parameters or variables. Note that the value of this property is completely worthless (undefined
) It only makes sense to use it after typeof
, the compile-time operator, in a type position, e.g.
let a: typeof Book.reduce.actionType;
A while ago I logged an issue requesting the ability to put type
declarations in interfaces, for this very purpose. If we could include a nested type Action
in the interface of a reducer, the above would become:
let a: Book.reduce.Action;
Composition through nested actions
A complex immutable data structure is built up by nesting to whatever depth we require. Indeed, if we were modelling a person's ancestry or the folder structure of a file system then the depth would be determined at runtime. There need not be any app-wide pattern for how to navigate the tree - each node is its own little domain that subdivides itself between further child nodes however it likes.
A given node of our structure may interpret an action itself, or it may need to delegate it to a child node. Therefore the child node should have its own reducer. By analogy with OO, the parent object's class has a method whose implementation calls onto a method in some other class of which the parent owns an instance.
In our example, a shelf contains many books. How do we send actions to a book with a shelf? We send an action to the shelf such that it figures out which book to send it on to. This is possible because the payload of an action can contain another action.
Here I'm going to send the shelf an action of type BOOKS
, which the shelf knows it must delegate to one of its collection of books:
{
type: "BOOKS",
payload: {
key: 3,
update: {
type: "SET_PRICE",
3.99
}
}
}
The outer action's payload has a key
, which identifies which book in the collection to delegate to, and an update
action, which must be an action supported by our Book
reducer (as indeed "SET_PRICE"
is - see above!). The Shelf
reducer asks the Book
reducer to create a modified book with the price change applied to it, and then in turn it creates a new shelf with the new version of book 3 in its collection, so the change ripples up the tree producing a whole new version (but naturally sharing all the unchanged nodes from the previous version).
So we can see how actions may be nested in a way that exactly maps to the nested structure to which they will apply. It's a very natural way to reflect composition of data structures in the composition of operations on those data structures.
And if we stick to this approach then we will always be able to exactly describe the types involved. This is very different from the composition approach of Redux's built-in combineReducers
. By design, that implies a front end interface that works by "action soup".
Let's call this an action path. It's purely an action from Redux's perspective, nothing magic about it. It just happens to follow a well-defined structural pattern.
Another nice thing is the polymorphism at work here. The type typeof Book.reduce.actionType
is a base type for all actions that can be applied to a book. So the type of the payload of the BOOKS
action (not that we ever have to manually declare it anywhere) is just:
{
key: number;
update: typeof Book.reduce.actionType;
}
The Shelf
reducer don't-give-a-F which particular action we want to perform on the book. It just provides a single, reusable conduit for such actions. We can declare further actions in Book
and never need to update the code for Shelf
at all.
Cursors, the Redux way
As I noted before cursors in the strict sense have been banned from Redux. But it's important to understand what they are and exactly what is non-Redux-y about them. That way, we can invent the Redux-compliant version by absolutely sticking to the rules of Redux and merely implementing a sufficiently cursor-like pattern to get the best of both worlds.
A cursor is a way of referring to a node nested deep within some immutable data structure, so that when you eventually decide how you want to modify that node, the business of "rippling up the tree" is automatically taken care of. You pass the cursor a new version of the object to go at that location, and the whole application's state tree transitions to a new state, and buried within it, at the corresponding location, is your new version of the node you actually wanted to alter. It's the immutable equivalent of an OO reference: you can pass it around and anyone who has it can both read and (effectively) "write" to that location, without needing to have intimate knowledge of the wider data structure it is embedded in.
So we definitely need something like that. But Redux says that updates must happen by dispatching an action to the store. Okay, so what if instead of a cursor taking a new version of a node it took an action to be dispatched to that node? The cursor is responsible for stitching that action inside another action, and that action inside yet another action, and so on until we have a composed action compatible with our whole store that makes the required update. Funny how we were only just talking about these action paths!
So, the Redux-approach version of a cursor can be thought of as a helpful way of composing action paths. If we have some completely independently-developed code module within a larger app, it can be given a cursor that allows it to obtain its current state, and also eventually update it by sending itself an action. It doesn't need to know what path that action will be wrapped in, and it doesn't need to know what data structure it lives inside. It might look like this:
interface Cursor<S, A> {
/**
* The value of the store at the time this cursor was created.
*/
readonly state: S;
/**
* Sends an action into the store's reducer, resulting in the
* store updating, and a new cursor is returned representing
* the new state.
*/
(action: A): Cursor<S, A>;
/**
* A cursor may address an object that no longer exists or
* hasn't yet been created, in which case the state will be
* the empty object for the type. The exists property can be
* used to unambiguously detect whether the object really
* exists.
*/
readonly exists: boolean;
}
It's a bit like a mini-store, and we could presumably have made the interface to a cursor a true subset of the Store
interface, which has getState
and dispatch
. But I want to make a clear distinction: Redux's Store#getState
is not a pure function, because it returns whatever the state is (or was) at the instant you called it; its value changes over time. This is a necessity because the store's very purpose is to be the one mutable object in the app, and to be a sub-hub that raises a notification whenever it mutates. Like a dumpster/skip/trash-can/rubbish-bin, it has to be messy in order to serve its purpose of containing the mess. But that doesn't mean we need to let that impurity leak out into the rest of the app! Let us, with one finger firmly in the dyke, hold to the principle that a cursor is immutable. So rather than a getState
method, it has a simple, readonly state
property, which never changes for the lifetime of the cursor.
Also, as it has one clear primary function, a cursor is a function - rather than it being an object with a dispatch
method, you simply call it as a function passing it an action. It cuts a lot of noise out of code. Though I still tend to think of this as "dispatching" to the cursor.
When you dispatch an action to the cursor, it returns a new Cursor
(another difference from Store#dispatch
), which gives you access to the updated state addressed by that cursor, should you need it. In a React app, you'll probably get re-rendered so might never use this capability. If you use Redux middleware, and it changes the fundamental nature of how the store works, you might get the same cursor back because although the built-in Redux store is strictly synchronous (brace yourself), middleware can change this.
Middleware considered harmful?
There are various ideas for how you might use the middleware feature in Redux. They seem like an unfortunate misstep to me. The beauty of Redux is that its central idea is so simple and general, it doesn't need to be changed. You can just build stuff on top of it, using it as a solid platform. But if you start changing the platform, you destroy the foundational assumptions that your work is based on. The basic principle that the store update is synchronous, not to mention the simplicity of the types involved, is really important.
The problem here is not with the ideas enabled by middleware (e.g. convenient orchestration of asynchronous operations). The problem is the idea that they should be grafted into the store itself, changing its basic nature. Instead, let's practice separation of concerns, and keep the store as a synchronously updatable container for a single immutable data structure. If we want other facilities that work differently alongside the store, we should implement such facilities separately, and have them dispatch actions to the store from the outside. No magic involved. A store is just a store.
Collections
Now let's think about the challenges involved in implementing cursors on a multi-nested, dynamically growing data structure. As an example we'll take another step back: we already have Book
, and a Shelf
that has a collection of books. Let's have a Shop
(UK English terminology to avoid clashing with Store
) that has a collection of shelves. There is obvious similarity between levels, and most apps will involve this kind of pattern. We need to be able to address things inside containers, at multiple levels.
Let's declare the Shelf
datatype:
interface Shelf {
readonly description: string;
readonly books: Map<number, Book>;
}
I'm using Map from immutable.js, and so each book has an ID number of some kind.
Suppose we have available to us another helper function, collection
, which defines how a collection of objects should work:
const books = collection({
type: "BOOKS",
reducer: Book.reduce,
operations: /* ... TBD... */,
get: (shelf: Shelf) => shelf.books,
set: (shelf, books) => amend(shelf, { books })
});
It returns a function books
that can be used to make cursors to books within the collection:
const book123 = books(someShelf, 123);
The first argument, someShelf
, is itself a cursor to a Shelf
(see how this gets nesty?) The collection definition has to specify (via its get
and set
functions) how to read and update the book collection inside a Shelf
. It also is told what reducer
to use on the collection items when they need to be updated via an action.
The operations
object, which I've left dangling until now, is separated out so that it can be defined once for some generic collection type (such as Map<K, I>
) and reused forever after. It has to conform to the following interface, and basically just provides the operations we require over a collection type, C
, using key K
and item I
:
interface CollectionOperations<C, K, I> {
get: (state: C, key: K) => { exists: boolean, value?: I };
set: (state: C, key: K, item: I) => C;
remove: (state: C, key: K) => C;
}
It lets us get a value (which might not exist), and also set or remove a value (non-destructively of course). This set of operations can be defined on a wide range of collections, and here's how it would be implemented for the immutable Map<K, I>
:
export function immutableMap<K, I>(): CollectionOperations<Map<K, I>, K, I> {
return {
get(items, key) {
return { exists: items.has(key), value: items.get(key) };
},
set(items, key, item) {
return items.set(key, item);
},
remove(items, key) {
return items.remove(key);
}
};
}
We could instead implement it to use a plain JS object as the collection. Anyhow, as well as being a function for making cursors, a collection such as books
also has type
and reduce
properties, as it is also an action definition, which means it can be added to a reducer:
export const reduce = reducer<Shelf>(empty)
.action(setDescription)
.action(books);
So once again we can wrap all these pieces up in a neat package:
namespace Shelf {
export const empty: Shelf = { ... };
export const setDescription = action("SET_DESCRIPTION",
(shelf: Shelf, description: string) => amend(shelf, { description }));
export const books = collection({
type: "BOOKS",
reducer: Book.reduce,
operations: immutableMap<number, Book>(),
get: (shelf: Shelf) => shelf.books,
set: (shelf, books) => amend(shelf, { books })
});
export const reduce = reducer(empty)
.action(setDescription)
.action(books);
}
So now I have a function Shelf.reduce
that is the reducer for a Shelf
object, and also a function Shelf.books
from which I can conveniently make cursors:
const restrictedShelf = // ... cursor from somewhere
const horcruxBook = Shelf.books(restrictedShelf, 666);
horcruxBook(Book.addAuthor("Voldemort"));
See how I'm dispatching to the book cursor? I don't need to dispatch to the shelf, and yet effectively that's what I'm doing. The book cursor wraps my "ADD_AUTHOR"
action in a "BOOKS"
action and dispatches it to the shelf cursor.
To tie this up to a Redux store we need a way to get a cursor from the store. That's what snapshot
is for:
// Make a store containing an empty Shop
const store = Shop.reduce.store();
// Get a cursor to the shop
const shop = snapshot(store);
// Get a shelf within the shop
const shelf = Shop.shelves(shop, "fiction");
// Get a book within the shelf
const book = Shelf.books(shelf, 109423);
// Update the book using an action
book(Book.setTitle("1985"));
// Check the store has been updated
expect(store.getState().shelves.get("fiction").books.get(109423).title).toEqual("1985");
This demonstrates another nice feature of these lazy cursors; if you ask for object that isn't in the collection, they act like it is and return you the appropriate empty object. So you can build structures with next to no effort. How do they know what the empty object should be? Well, we've told them! When we declare a collection
we give it the reducer for its items, e.g. Book.reduce
, which has an empty
property specifying what an empty Book
should be.
Want to try it for yourself?
I've put these various utilities together in a library called Immuto:
npm install immuto
Naturally it has built-in type definitions, no need to install any separately. If you look at the rough tests in immuto/spec you'll see they're based on the same book/shelf/shop example I used here.
Happy type-safe composable Reduxing!