Reducer composition in Reason
May 21, 2016
Reason is a new syntax and set of developer tools for the OCaml programming language. Of all the statically typed functional languages available I've always found OCaml the most interesting. OCaml (and I hope, by extension Reason) seems to be a very pragmatic language, used in many real world situations (fftw, mirage, flow etc.) in a wide variety of fields. There is also really good online learning materials for beginners like myself. When Reason was released last week I couldn't wait to dig in and learn more about both Reason itself and the underlying concepts in OCaml.
A word of warning: I am not very familiar with statically typed functional programming. I've dabbled a little bit in Haskell, OCaml and Elm but never created anything substantial. Different languages often solve problems in very different ways and what I've attempted in this post might very well be something you would never do in this kind of programming language. This is a learning exercise for me and I have not much prior experience to guide me.
The problem I'm trying to solve is the same one as in my previous post (Redux reducers in Clojure) which is a reimplementation of the combineReducers
function from the redux library.
The idea is that you have a potentially large immutable store
and a reducer
function which, given some action
will create a new version of the store
.
The store can contain many different kinds of entities but often has a rigid base structure. In this example the store looks kind of like
{
contacts: _
emails: {
inbox: _
sent: _
}
}
There are also many different types of actions, for example
- Add an email to the inbox
- Remove a contact
- etc.
To recap: There is one immutable store and one reducer function which should handle many different types of actions and route the desired update to the correct position in the store.
The challenge is to somehow build this top level reducer function out of smaller pieces in an attempt to keep things more manageable. This is solved quite elegantly in redux with its combineReducer
higher level function. You create smaller reducer functions and combine them into the top level one:
const rootReducer = combineReducers({
contacts: contactsReducer,
emails: combineReducers({
inbox: inboxReducer,
sent: sentReducer
})
});
But enough with the Javascript. This post will attempt to build something similar in Reason.
In Clojure (and Javascript) you often use heterogeneous collections such as maps, vectors and sets to represent things like (in this example) contacts and emails. This has the advantage that you can jump straight to the implementation. In a statically typed language like Reason you instead invest more time in modelling and specifying the data types your program handles. The hope is that the result will be less prone to certain kinds of type related bugs. With data and function types rigourously specified the code can perhaps even be more maintainable as many design decisions are more explicit.
Domain types
A contact
is a record with an id and an email:
type contact = {
id: int64,
email: string
};
We will model our contacts as an immutable map where the id of a contact maps to the contact itself. For this we need to create a new Contacts
module:
let module Contacts = Map.Make Int64;
The Contacts
module can be thought of as a map implementation specialised at handlings maps with keys of type int64
. This module will contain all the functions we need to add/remove contacts, and of course lookup contacts by id. I find it very interesting that you need to specify the type of the key at this point, but not the value type.
An email
is also represented as a record:
type email = {
from_addr: string,
to_addr: string,
title: string,
body: string
};
For some reason I chose in the previous post to model the inbox as a set of emails. This turned out to be an excellent challenge. Records in Reason are sortable if the corresponding values are. Since all values in our email type are strings we can use the compare
function directly:
let email1 = {
from_addr: "flint@example.com",
to_addr: "strax@example.com",
title: "Hi!",
body: "..."
};
let email2 = {
from_addr: "strax@example.com",
to_addr: "flint@example.com",
title: "Re: Hi!",
body: "..."
};
compare email1 email2;
The specific sort order doesn't actually matter for our use case but it is important that some consistent sort order is defined for the sake of efficient set operations (for example, is a given email in the inbox or not).
To be able to create "a set of emails" we need to define an Email
module that describes how to sort emails. If we wanted to we could define our own way of sorting at this point.
let module Email = {
type t = email;
let compare = compare;
};
We can now create an Inbox
module which will, similarly to the Contacts
map above, contain a set implementation that can efficiently store and retrieve emails.
let module Inbox = Set.Make Email;
We will use functions like Inbox.add
and Inbox.remove
later for adding/removing emails.
Finally, we will also store "sent" emails in the store
. This will simply be represented as a linked list of emails (which has the type list email
in Reason).
Actions
Actions will also be statically typed, modelled as variants instead of records. We could have a single action type which lists all the possible actions, but I chose instead to split the actions into contact_action
and inbox_action
.
type contact_action =
| AddContact of contact
| RemoveContact of int64;
type inbox_action =
| AddToInbox of email
| RemoveFromInbox of email;
These two types are composed into a top level action
type:
type action =
| ContactAction of contact_action
| InboxAction of inbox_action
| EmailSentAction of email;
Reducers
A "reducing function" or "reducer" is the function that's the first argument to List.fold_left
(often called reduce
in other languages). The reducers we will write will take some piece of the store
as the first argument and an action
as the second. The result will be a new store
value with the action incorporated. Let's start with the contact_reducer
:
let contact_reducer contacts action =>
switch action {
| ContactAction (AddContact contact) =>
Contacts.add contact.id contact contacts
| ContactAction (RemoveContact id) =>
Contacts.remove id contacts
| _ => contacts
};
The reducer takes an action of type action
and not the smaller type contact_action
. This is because we are modelling how reducers work in redux. The rationale is that a single action can affect many parts of the store. Say for example that the contact
record also contained a count
where we counted the number of interactions (sent and received emails). We could then hook into other kinds of actions, e.g. an SentEmailAction
to be able to keep that count without creating additional InboxActions
. Also, if the action is not handled the contacts are returned unchanged (handled byt the "wildcard" case _ => contacts
).
Another requirement for reducers in redux is that if the store
is undefined
the reducer should return the initial value for the store. I couldn't figure out a nice way to model this so I have to cheat a bit and define the initial state of the entire store explicitly later.
The inbox_reducer
works similarly but updates Inbox
sets instead of Contacts
maps:
let inbox_reducer inbox action =>
switch action {
| InboxAction ia => {
switch ia {
| AddToInbox email => Inbox.add email inbox
| RemoveFromInbox email => Inbox.remove email inbox
}
| _ => inbox
};
Previously, in contacts_reducer
we used only one switch statement to handle all the actions. For inbox_reducer
we're instead using nested switch statements to handle InboxActions
separately. There is an advantage to this that's perhaps not immediately apparent. If we create a new kind of InboxAction
later, and forget to handle it in the inbox_reducer
the compiler will actually be able to warn us about this potential mistake.
Finally, the sent_reducer
simply adds emails to the front of a linked list
let sent_reducer sent action =>
switch action {
| EmailSentAction email => [email, ...sent]
| _ => sent
};
The store itself is modelled as nested records
type email_store = {
inbox: Inbox.t,
sent: list email
};
type store = {
contacts: Contacts.t,
emails: email_store
};
Higher order reducers are combined manually until we finally define the root_reducer
:
let email_reducer email_store action => {
inbox: inbox_reducer email_store.inbox action,
sent: sent_reducer email_store.sent action
};
let root_reducer store action => {
contacts: contact_reducer store.contacts action,
emails: email_reducer store.emails action
};
To test this out we explicitly define an initial store and a list of actions
let initial_store = {
contacts: Contacts.empty,
emails: {
sent: [],
inbox: Inbox.empty
}
};
let actions = [
EmailSentAction {
from_addr: "flint@example.com",
to_addr: "strax@example.com",
title: "Hi!",
body: "..."
},
ContactAction (
AddContact {id: 101L, email: "vastra@example.com"}
),
InboxAction (
AddToInbox {
from_addr: "strax@example.com",
to_addr: "flint@example.com",
title: "Re: Hi",
body: "..."
}
),
ContactAction (
AddContact {id: 102L, email: "flint@example.com"}
),
ContactAction (RemoveContact 101L),
ContactAction (
AddContact {id: 103L, email: "strax@example.com"}
)
];
We can load all these top level expressions into the rtop
repl using #use "Reducers.re"
or simply by copy pasting each form one by one into the repl. Finally, we can apply the list of actions to the initial store using List.fold_left
(Reason#
is the repl prompt):
Reason# let result = List.fold_left root_reducer initial_store actions;
let result : store =
{contacts : <abstr>,
emails :
{inbox : <abstr>,
sent :
[{from_addr : "flint@example.com", to_addr : "strax@example.com",
title : "Hi!", body : "..."}]}}
Unfortunately maps and sets are printed as <abstr>
by the rtop
repl but I'm just going to trust the "if it compiles, it works" mantra and assume it's the correct result. It is possible to tell the repl how to print custom data types, but this can be a topic for a future blog post.
For now we can also manually check contacts with
Reason# Contacts.bindings result.contacts;
- : list (int64, contact) =
[(102L, {id : 102L, email : "flint@example.com"}),
(103L, {id : 103L, email : "strax@example.com"})]
and the inbox using
Reason# Inbox.elements result.emails.inbox;
- : list email =
[{from_addr : "strax@example.com", to_addr : "flint@example.com",
title : "Reply: Hi", body : "..."}]
Conclusions
You might have noticed that I never actually defined the combineReducers
function. This is simply because I couldn't figure out how to do it. Instead I manually built the root_reducer
out of other reducers. I'm sure it's possible to build a generic function like combineReducers
in Reason and perhaps with more experience I'll be able to do it.
On the other hand there isn't much of a practical difference between
let email_reducer email_store action => {
inbox: inbox_reducer email_store.inbox action,
sent: sent_reducer email_store.sent action
};
let root_reducer store action => {
contacts: contact_reducer store.contacts action,
emails: email_reducer store.emails action
};
and
const rootReducer = combineReducers({
contacts: contactsReducer,
emails: combineReducers({
inbox: inboxReducer,
sent: sentReducer
})
});
What we achieved instead might be just as valuable since everything is type checked at compile time, including the actions and the store itself.
Finally I would like to thank @pycurious, @_chenglou, @jordwalke, @jeffmo and @reynir (on irc, #reasonml) for very valuable feedback on earlier drafts of this post.