DEV Community

loading...

ReasonML - my best practices

Tomasz Cichocinski
・10 min read

Programming is constant learning. Improving your skills, learning new things, establishing best practices. I want to share a few best practices I developed based on my experience in various Reason projects. Some of those are BuckleScript specific and some of them apply to Native ReasonML development as well.

This post contains only a list of few that I find important in my projects but Leandro Ostera collected a great set of design patterns and best practices: https://github.com/ostera/reason-design-patterns - definitely check them out!

In this article I want to focus on the following patterns and best practices:

  • Project structure
  • Creating data modules to hide your implementation details and place your logic in one place
  • Always defaulting to Belt
  • Using Pipe first

Project structure

It's an adoption of Yavar Amin's Project structure but adjusted to my needs and project size. I highly recommend reading the mentioned article.

My setup is very similar. I try to divide my application into business entities and build folders structure around them. Let's take a basic blog application as an example. First thing every blog application needs are posts. Because of that src/post would be my first folder with a single Post.re in it. ReasonML module system is folder agnostic so I don't have to import it in any of my other modules - Post is available globally. That module would have one main purpose - providing a clear list of all submodules that are available for use outside of src/post.

Alongside Post.re I would have Post_Query.re reexporting all my GraphQL queries like Post_Query_Single.re or Post_Query_List.re. If you're using REST instead of GraphQL the same applies. My React component could be Post_View_Single.re containing single post logic. It would be reexported in Post_View.re and then in Post.re for usage in other parts of the application.

In the future, when the application would grow I could add comments functionality with src/comment and Comment.re reexporting all my other modules: Comment_View.re, Comment_View_List.re and Comment_View_Single.re.

I found this structure work well in medium size with projects - between 200-300 files and 20-30k lines of code. None of my applications surpassed that numbers so far. If any of them will get bigger and this structure will become hard to maintain, I'll think about changing it. There is no need for prematurely optimizing it as ReasonML gives us strong guarantees when refactoring 😎. For me, it's also important to remember that this is only a framework. You don't have to follow this religiously. Try it and adjust to your needs and your application requirements.

Create data modules to hide your implementation details and place your logic in one place

I also wrote about creating modules hiding your implementation details in my previous post: about lessons learned creating a first Reason production app: First ReasonML production app - lessons learned.

As we covered folders and file structure it's important to think about what would be in them. When I started programming in JavaScript I had no idea how to structure my projects. I randomly created React components when they were required and objects in random places with random shapes. Over time I paid greater attention to my React code structure and this part improved. Later, when I learned about Flow I started caring more about types and where they are located in my project. But my logic tended to be scattered in random places. It was painful.

  • I haven't always had type definition for data, a lot was relying on type inference
  • When I moved my React components, a lot of functions defined in the same place changed place as well thus requiring import fixes
  • I had random functions scattered across all my codebase doing random stuff with my data. Not everything was wrong (like mutating data or firing side effects) but it was a mess.
  • Code discoverability was super hard - to find if the logic I need is already implemented or do I have to write everything from scratch, I had to browse through dozens of files and rely strongly on my memory. I don't recommend anyone joining a team working on such codebase 😅

When we started using Reason, a lot of bad practices migrated with us from JavaScript into our new Reason code. More powerful language doesn't mean you automatically write better code. It takes time to learn how to use this power correctly.

In functional programming languages, you usually have two most important constructs you can use to create bigger abstractions: functions and modules. Functions are the basic logic primitives you can combine into larger pieces of logic and modules which are a collection of your functions.

Let's dive into a simple example. Imagine we have a refund policy in our application. Communication between our client and server is done via GraphQL thus our refund policy will be GraphQL Enum type - in Reason, it will be a polymorphic variant.

If you want to better understand the difference between different types of variants in Reason I highly recommend this article by Dr. Axel Rauschmayer about polymorphic variants.

What we want is basicly 1 Module 1 Thing pattern. Our basic module can look as follows:

/* RefundPolicy.re */
type t = [ | `FullRefund | `NoRefund];

Great, we have our type defined now we can move to our functions. Because we're working on frontend application, sooner or later we will have to show refund policy to the user. Let's introduce the toMessage function. It takes our type t (following OCaml convention for definition leading type in the module) and outputs i18n message our UI can display:

/* RefundPolicy.re */
type t = [ | `FullRefund | `NoRefund];

/* Using bs-react-intl for i18n */
let messages =
  [@intl.messages]
  {
    "fullReturn": {
      "id": "refundPolicy.fullReturn",
      "defaultMessage": "Full return",
    },
    "noReturn": {
      "id": "refundPolicy.noReturn",
      "defaultMessage": "No return",
    },
  };

let toMessage = fun
  | `FullRefund => messages##fullRefund
  | `NoRefund => messages##noRefund

Great, we have a way to display our refund policy to the user! But what if the user needs the ability to select the option? Well, we need more functions.

Usually, React select components work on string values so it would be nice to convert our refund policy to string and from a string. This is where BuckleScript converters come handy, one in particular: jsConverter. It gives us the ability to convert our polyvariant into a string and from a string. It's also a great option if only part of your system is written in Reason and other in Flow/TypeScript where enums are usually represented as strings. Let's use that!

/* RefundPolicy.re */
[@bs.deriving jsConverter]
type t = [ | `FullRefund | `NoRefund];

let default = `NoRefund;

let toString = tToJs;

let fromString = value =>
  value
  ->tFromJs
  ->Belt.Option.getWithDefault(default);

This way you can safely convert your polyvariant to and from a string. tFromJs returns option(t) so you can unwrap that early or return option(t) and handle such case later. It all depends on your use-case. As many BuckleScript interoperability helpers jsConverter have few handy features. One of them is [@bs.as]. It allows you to customize how each case is converted to a string. By default they are all converted as-is: FullRefund will become "FullRefund" and myOtherCase will become "myOtherCase". You can use [@bs.as "full-refund"] if you need different output:

/* RefundPolicy.re */
[@bs.deriving jsConverter]
type t = [
  /* will output "full-refund" */
  | [@bs.as "full-refund"] `FullRefund
  | `NoRefund
];

When we have a way to put it into select, it would be great to provide variable providing all available options. Of course, you can get this data from backend if it's not constant and rely on your business logic but let assume this list is constant:

/* RefundPolicy.re */
let all = [| `FullRefund, `NoRefund |];

let options = all->Belt.Array.map(policy => {
  "key": toString(policy),
  "message": toMessage(policy)
});

Now you have a pretty nice module containing all your logic. In the future, when your business logic changes and available refund policies change, you only have to change code in one place to match new requirements.

Of course, the above functions are trivial but this example is to illustrate the concept. You can create as complex modules as your business requires and restrict access to internals using interface files exposing only public API. It all depends on the size of your team and your codebase.

Always default to Belt

Belt is a BuckleScript standard library. It provided a set of data structures and modules to interact with them. Modules like Belt.List, Belt.Array, Belt.Option and Belt.Result are compatible with built-in Reason types but provide functions that have consistent API and great performance in JavaScript runtime. When I started using Reason, I had few problems with Belt. Mainly because I loved pipe last operator |> and all Belt functions have pipe first -> API (I'll explain differences between |> and -> in-depth in next chapter) and good documentation was lacking (now there is ongoing process of creating better documentation for ReasonML ecosystem).

As of today, Belt comes with BuckleScript compiler and people starting with Reason are confused about what they should use: Array or Belt.Array module? It's probably ok to mix that up when you're working on a toy project but the problem starts when you have a team of few developers working on a bigger project.
Some of them use open Belt on top of a file, some don't. Some use Belt.Array and some Array module. In general, it gets quite messy fast. You can enforce code style in code reviews but it gets tedious. What I started doing is adding -open Belt flag to bsc-flags in bsconfig.json. It implicitly opens Belt in all your modules. Some people may agree, some may not but I think it helps keep certain conventions in a project.

If you don't like Belt and want to use other libraries like tablecloth or relude you can do the same. It works perfectly fine. Problem with Belt can be versioning reliable on bs-platform release, the slow pace of adding functions and modules required by community. There are plans for extracting it from BuckleScript.

Use Pipe first

Yeah, right. One of those great battles in functional programming and BuckleScript took that to the extreme.

Pipe operators, at this point, exist in quite a lot of languages: F#, Elm, Elixir, Reason. A lot of people debate about the "data" place. Some languages like Elm use data last approach, where data is the last argument of a function, and other languages like Elixir prefer the data-first approach - data is the first argument.

Here comes Reason - exactly BuckleScript, distorting well-established practices used in OCaml. In OCaml, where pipe operator is not shipped in the standard library (Yavar Amin corrected me that OCaml std skips with a pipe last operator - thanks 😅) people are implementing it in userland as it's just a one-line infix function:

let ( |> ) x f = f x

Native Reason comes with |> by default. It's data last because of currying and partial application. When you create a function, you sometimes want to omit data passed and create a partially applied function with a logic you created. Let's take List.map as an example.

let doubleElements = collection => List.map(x => x * 2, collection);

It allows you to double every element in the list. With data last approach and partial application you can shorten it as follows:

let doubleElements = List.map(x => x * 2);

It means the same thing and is preferred by some people.

Using the pipe last approach you can use it as follows:

[1,2,3] |> List.map(x => x * 2)
/* or */
[1,2,3] |> doubleElements

It's great because you have a nice flow of how your data is transformed.

[1,2,3]
  |> List.map(x => x * 2)
  |> List.keep(x => x mod 2 == 0)
  |> List.head
  |> Option.map(string_of_int)
  |> Option.map(number => "You happy numer is: " ++ number)

The problem started when BuckleScript introduced -> (pipe first) operator. It behaves similarly but inserts the left side of the operator as the first argument of a right side function:

[1,2,3]
  ->Belt.List.map(x => x * 2)
  ->Belt.List.keep(x => x mod 2 == 0)
  ->Belt.List.head
  ->Belt.Option.map(string_of_int)
  ->Belt.Option.map(happyNumber => "You happy numer is: " ++ happyNumber)

And now all your existing libraries, including OCaml/Reason standard library which is data last, are not working with BuckleScript pipe first operator.

Why is that you may ask? There are a few reasons:

  • -> unlike |> is just a syntax sugar - it's removed during compilation. Pipe last is just a function and it's not so easy to remove. So in the above example, you have more function calls.

  • Pipe first has higher associativity than pipe last, thanks to that you can do things like this.

/* without -> */
event => ReactEvent.Form.target(event)##value
/* with -> */
event => event->ReactEvent.Form.target##value

It's something JS API specifics and BuckleScript tries to optimize such cases.

  • Pipe first works better with BuckleScript bindings: [@bs.send], [@bs.set], etc where data is required to be first
type myComplexClass;

[@bs.new]
external makeComplexClassInstance: unit => myComplexClass = "ComplexClass"

[@bs.send]
external callMeMaybe: (myComplexClass, option(string)) => unit = "callMeMaybe"

myComplexClassInstance->callMeMaybe(None)
myComplexClassInstance->callMeMaybe(Some("911"))

My rule of thumb is using pipe first. When it's not possible BuckleScript supports pipe placeholder which makes it compatible with data last collections:

[1,2,3]->List.map(x => x * 2, _);

Reason community seems divided because the pipe first doesn't work on Native. But I think people working with JavaScript as Reason target are embracing pipe first approach. Some libraries like darklang/tablecloth are using labeled arguments to support both pipe first and pipe last approach:

[1,2,3] |> List.map(~f=x => x * 2);
[1,2,3]->List.map(~f=x => x * 2);

If you want to read more about the differences between those two approaches, there is a great article by Javier Chávarri which goes into details about differences between pipe first and pipe last in BuckleScript.

Summary

I hope those few things will help you in the current and future projects and allow you to ship great features! If you have some other practices you follow - share and discuss them with the community!

Many thanks to @zth, @jfrolich and @Faliszek for proofreading!

Discussion (2)

Collapse
yawaramin profile image
Yawar Amin

Great post Tomasz, I really liked the module design :-)

Btw pipe-last is shipped with OCaml standard library: caml.inria.fr/pub/docs/manual-ocam...

Collapse
baransu profile image
Tomasz Cichocinski Author • Edited

Good to know. Thanks! I’ll add a note about it 🙂

Forem Open with the Forem app