Or: Reasonable functional programming with Reason, part 1:
A short introduction to ReasonML types and functor types from a beginner.
I started to learn some functional patterns after reading Brian Lonsdorf’s amazing book “Mostly Adequate Guide to Functional Programming in JavaScript”. This book made me crazy about this new paradigm, that I never learned, and made me want to practice these patterns somewhere.
I found myself using it in my fun projects but never in my real-work projects — it always fell in the “most javascript programmers don’t code like this” territory. My colleagues were right, so I decided to learn a different language. Originally, it was Haskell. I started to learn it, but it was too theoretical (or “academic”) for me: I’m a very practical guy, and I like to be able to use and build software with the paradigms and languages I learn first.
So… for the last couple of days, I’ve been learning Reason.
Reason?
Reason, or ReasonML, is a new syntax of OCaml. It looks more like JavaScript so it is easier to get started with. And the OCaml part doesn’t need to tell you much, other than that you have a big community behind you, in a language that is here for years. OCaml has opam as their package management tool, and the community is active. Check out awesome-ocaml to see some cool stuff from the OCaml community.
What does “a new syntax” mean? Having a new syntax for OCaml is like using CoffeeScript or transpiling newer JS syntax to older JS syntax using Babel — writing in one syntax, then compiling it to another syntax. Reason “compiles” to OCaml, and along with BuckleScript, a tool that compiles OCaml to very efficient JavaScript, you can see the chain: Reason ➡ OCaml ➡ JavaScript.
Both OCaml and Reason have a full sound (and inferred) type system, so 100% of your code is covered with types on compile time. That means fewer bugs. The inference part is amazing too; it means that you don’t have to write the types yourself. The compiler understands your code and does that for you.
For developers coming with Java-like type systems, to type systems like Reason has — note that in Reason, nulls aren’t a thing. You can’t send null as an argument for a function which wants to get a string. It’s just not possible. It’s not that the whole concept of absence isn’t baked into Reason, it is just that you have a special way of doing so. That way is called option
.
The option
type
In Reason, you can declare types as constructors. Constructors can have data in them, but it is not mandatory. Let’s take the option
type, for instance, its declaration looks like the following:
type option('a) = None | Some('a)
Woohoo, there’s a lot going on here for first comers. Let’s go over this definition together.
First, we declare a type
, called option
. Then, there are the type parameters: that 'a
thing is called “type parameter” — it is used like Generics or Templates in languages like Java, C++, etc. This is so option can “wrap” many different types: you can have option(int)
if you have a nullable integer, or option(string)
if you have a nullable string! Type parameters are always prefixed with a tick ('
), so you can’t unsee it in examples. You can have more than one type parameter — more on that later.
Then, we say that our type is a union of 2 constructors: None
, which receives nothing as data, and Some
, which receives data with the type 'a
, as the type parameter says.
The built-in option
type is the way we handle absence in Reason. In Haskell, it is called Maybe
. We always know that we receive an option
type, and when we have an option
type, we always have to unpack it before we use it. We unpack data with a cool feature called pattern matching:
let greet = (optionalName: option(string)) : string =>
switch optionalName {
| Some(name) => "Hello, " ++ name
| None => "Who are you?"
};
print_endline(greet(Some("Gal"))); /* prints "Hello, Gal" */ print_endline(greet(None)); /* prints "Who are you?" */
See how I unpack the data with ease. When I get None
, I return a string that contains Who are you?
. When I get Some
with some name in it, I return Hello, ${name}
. It may look weird that it actually works like that, but it does! (you can click here to see what it compiles to in the Reason Try page)
Okay okay… an optional type. What is a functor?
Now that we know what is the option
type, we can continue from here and use it as our example for the rest of the article.
A functor is a data type that can be mapped. That means that you can call the map
function on it. The map
function takes a functor and a function. Then, applies the provided function to the functor’s value (unwraps it), and wraps the result with an instance of the functor again.
You may already be familiar with Array#map from ES5: array.map(fn). This is because Array is a functor that has multiple values in it. The provided function is evaluated with every value in the array, and in return, you get a new Array! By the way, in Reason, you don’t call methods on a value — that’s OOP. The way of invoking methods is to invoke a function and pass the value to it:
let increment = x => x + 1;
map(Array[1], increment) /* => Array[2] */
The option type, Just like Array, is a functor too. To understand how it would work let’s split it into its two constructors: Some and None.
What would happen if we map over None
? I’d suggest that it should have the same behavior as mapping over an empty array: it should do nothing, it shouldn’t execute the provided function at all — it should just return None
. For Some
, I’d suggest that it should have the same behavior as mapping over an array with a single value. So the single value will be applied to the function, then be packed again with Some
:
let increment = x => x + 1;
map(None, increment); /* => None */
map(Some(1), increment); /* => Some(2) */
Implementing this map function is just a couple lines of code:
let mapOption = (opt, fn) =>
switch opt {
| None => None
| Some(value) => Some(fn(value))
};
Reason’s amazing type system ensures that fn
is a function that receives the provided option value, and returns a new option. The full type declaration for mapOption is:
let mapOption = (opt: option('a), fn: 'a => 'b) : option('b)
Let’s go over it together: we first declare a function called mapOption
that receives an argument called opt
, with the type of option('a)
. As we already talked about, 'a
is a type parameter, making this function get any type of option
. then, we receive a function that receives 'a
(the type parameter we had in the opt argument), and returns 'b
, which is or isn’t a different type. Then, the function returns option('b)
, which is the same type as the inner function result type but wrapped in an option
.
In other words, we receive an option('a)
, then we receive a function that transforms a value from type 'a
to 'b
, and then we return option('b)
: we support transforming between the inner types, while we’re still in option
land:
let stringify = x => string_of_int(x); /* int => string */
mapOption(Some(1), stringify); /* => Some("1") */
mapOption(None, stringify); /* => None */
More functor examples
We can declare another type — the result
functor. The result functor will be almost like option
, but its negative side will have a value too. You can treat its value as a reason
, or error
, or just failure
. When you return the result
functor, you’re basically saying that “this method may fail”. You can use result in validations and for database queries, or for anything whose error is important:
let result('s, 'f) = Success('s) | Failure('f)
The map behavior is almost identical to the optionMap
behavior. If we have Success
, we will take its data and apply it to the provided function. If we have a Failure
, we wouldn’t apply the value to the function, and just leave that Failure
as it is. The implementation, again, is a couple lines of code:
let mapResult = (res, fn) =>
switch res {
| Failure(failure) => Failure(failure)
| Success(success) => Success(fn(success))
};
This data structure helps us build our program as a tree. When we have a problem, we stop applying our logic to it. We basically stop going through the “happy path” until we handle the failure. When we map over Failure
, we stay with the same failure we had, so no data transformation will happen once we have a Failure
.
In the next article, I’m going to write about Monads, a special case of Functors, that can help you a lot while building data transformations.
Up until then, what are your ideas of custom functors?
Thanks for reading!
Top comments (0)