Tales of a Sardinian software engineer - Functional Friday Episode 4 - Learning Rescript - How to use recursion, how to use async functions and how to use pipes

Functional Friday - Episode 4

Learning Rescript - Recursion, Async/Await and Pipes


Welcome back to Functional Friday, the place where you can't distinguish a chicken breast from a function, since they're both curried 🥁! In the last last episode we started exploring ReScript functions, going through main differences compared to JavaScript and TypeScript. Because so, it's considered as a prerequisite before jumping in today's episode, so if you haven't read it yet I highly encourage you to do so.

I consider the topics that we're going to cover today as advanced ones, even if they will probably become part of our daily tools. So don't feel discouraged if they'll sound complex at a first glance: they are.
As always, take your time to digest each paragraph and don't be ashamed to read this article multiple times if needed. Eventually, feel free to drop a comment and I'll do my best to answer to it 🙂.


4.1 Recursive Functions 🔃

If you read the previous articles, you would have probably noticed how we didn't mentioned anything about loops: while and for statements exist in ReScript and their syntax is pretty much equivalent to other programming languages.

RES
let destinations = ["Italy", "Spain", "Australia"]

let iFirst = 0
let iLast = Js.Array2.length(destinations) - 1

// We can iterate using a for loop from first to last position ...
for i in iFirst to iLast {
  Js.log(destinations[i])
}

// ... or in a inverted fashion from last to first ...
for i in iLast downto iFirst {
  Js.log(destinations[i])
}

let iCursor = ref(iFirst)

// ... or by using a while loop.
while iCursor.contents < iLast {
  Js.log(destinations[iCursor.contents])
  iCursor := iCursor.contents + 1
}

Aside from some minor caveats, I think there's not that much to say about the last snippet. The most interesting thing probably lies in the following lines

RES
let iCursor = ref(iFirst)

while iCursor.contents < iLast {
  ...
  iCursor := iCursor.contents + 1
}

where we used a mutable reference to break the loop. We're not going into details about mutable references here [1], instead what we want to focus on is the usage of an escape hatch to stop a loop statement.

This happens because in ReScript, as in other functional programming languages for that matter, loops are second-class citizen: they exist and can be used to some extent, but they don't have the same level of support you may find in any imperative language [2]. From an idiomatic point of view, in functional programming recursion is way more used than loops to iterate over data.

In ReScript, recursive functions must be explicit and because so we need to add the rec keyword after let, as shown in the following example:

RES
// Note: this will run forever
let rec allNightLong = () => allNightLong()

Of course, recursion is meant to terminate at some point. Now, let's suppose we want to use a recursive function to print out all the values in our destinations array. We can write:

RES
let rec printDestinations = (destinationList) => {
  if Js.Array2.length(destinationList) == 0 {
    Js.log("")
  } else {
    Js.log(destinationList[0])

    // @see: https://rescript-lang.org/docs/manual/latest/api/js/array-2#filteri
    printDestinations(Js.Array2.filteri(destinationList, (_, index) => index != 0))
  }
}

printDestinations(destinations)

achieving exactly the same result, but with a much simpler code. In fact, even if at a first glance recursion may be intimidating there're few things we can notice when comparing our recursive function to equivalent loop statements:

  • we don't need to track any index;
  • we don't need any mutable reference, we kept our code immutable;
  • we always accessed the first position, filtering out the elements we're not interested into anymore in the next recursion.

Overall we can say our code is now cleaner and simpler to read and maintain. At the same time I'd like to mention that recursion allow us to implement our last function way more elegantly than we did, especially when combined with other ReScript features. For now though we'll stick with the features we learned up to this point.

Last but not least, ReScript allows mutually recursive functions by using the keyword and in conjunction with rec as shown below

RES
let rec addOne = (val) => {
  if (val > 10) {
    val
  } else {
    multiplyByTwo(val + 1)
  }
}

and multiplyByTwo = val => {
  if (val > 10) {
    val
  } else {
    addOne(val * 2)
  }
}

where the call order do not matter from a syntax perspective [3].


4.2 Async / Await ⏳

Asynchronous programming is a big part of JavaScript development: dealing with deferred values, promises, remote API calls, etc. is pretty much an every day task. ReScript team knows this and worked hard to give us a proper syntax to handle asynchronous work and from v10.1 ReScript offers an equivalent to JavaScript async / await. Now, for the syntax, is really similar to JavaScript one:

RES
let createNewAccount = async (username, password) => await storeNewUser(username, password)

now, there're two important things to keep in mind when operating with asynchronous functions:

  • we may use await only inside async functions;
  • we may call await only on deferred values (a.k.a. promise value)

which is something expected if you're familiar with JavaScript/TypeScript. On the other side ReScript promises do not automatically flatten, from a type perspective. This is somehow interesting, especially when compared to TypeScript: in fact, if we consider the following code

TS
const doSomething = () => new Promise((resolve) => resolve("done"))

const taskList = async () => {
  return doSomething()
};

we can see how taskList return type is inferred as Promise<unknown>
In ReScript however, this is not true as

RES
let doSomething = () => Js.Promise2.make(((~resolve, ~reject as _) => resolve(. "done")))

let taskList = async () => {
  doSomething()
}

will result in taskList return type being promise<promise<string>>. While this have no implication at run-time, at compile time we have to remember to manually unwrap any nested promise; otherwise the compiler may potentially rise an error (this can be quite common when explicitly declaring types and signatures) [4].


4.3 Pipes →

If you have any experience in functional programming, chances are you're already familiar with function composition. In case you're not, no worries: I got you covered.
Function composition is a mathematical operation that takes two functions f and g and returns a function as result which applies g to f, so that h = g ∘ f → h(x) = g(f(x)). In programming, function composition is generally used to indicate whenever we pass a function as argument to another function and we get a result which is the composition of the results of each function.

So, imagine having a function addOne(x) and a second function multiplyByTwo(x): being able to compose these functions means we can write addOne(multiplyByTwo(x)) or multiplyByTwo(addOne(x)). A function that takes a function as an argument and/or return a function as result is higher order. Both JavaScript/TypeScript and ReScript support higher order functions, but ReScript also has a very small yet ergonomic operator to compose functions that goes under the name of pipe operator, written as ->.

Considering the functions addOne and multiplyByTwo we just mentioned, using the pipe operator we can write

RES
// addOne(multiplyByTwo(x))
let result = x->multiplyByTwo->addOne

// multiplyByTwo(addOne(x))
let result = x->addOne->multiplyByTwo

Combined with block scoping, pipes can be used also with async values

RES
let getRemoteUserName = async() => {
  let userName = {await getUser()}->getFirstName
  username
}

so our code will not only result cleaner, but it's more readable as well, since it reads left to right without having to wrestle with multiple parenthesis while reading right to left.

For sake of comparison, Javascript nor TypeScript have a similar operator, at least not yet: there's in fact a proposal currently at Stage 2 for introducing a pipe operator in JavaScript as well. Personally, I hope it will be introduced as soon as possible, so let's keep our fingers crossed 🤞.

4.3.1 Notes about function composition and pipe usage

When talking about function composition, there're some important implications to keep in mind:

  • in order to be able to apply g to f, f Codomain should match g Domain and vice versa [5];
  • function composition does not necessary commute, so g(f(x)) may produce a different result from f(g(x));
  • f and g are inverse functions if f(g(x)) = x for all x in g Domain and g(f(x)) = x for all x in f Domain;

you will probably be wondering why we had to get through all of this mathematical definitions when we can get away without having a proper understanding of the aforementioned considerations and learn just by practice or by examples.
In my opinion knowing a little bit of mathematics here helps understanding not only how the type checker works and makes reasonable assumption about your code, but will also help developers understanding better how function composition works and write functions that are actually composable in first place.

On the other hand, when it comes to pipe operator usage, the only thing I want you to keep in mind is: the main goal of this operator is making function composition easier (or more natural) to read. So please don't abuse it.

If you're curious to know more about pipes, I encourage to have a look at the pipe documentation page.


4.4 Let's wrap it up! 🫡

As always, thanks for reading through the whole article: I really hope you enjoyed the content.
As we saw, our ReScript vocabulary keeps growing and we know have some serious tools under our belts. We're also getting a little bit more technical about certain aspects and I hope all the things I mentioned in this episode will help you in your ReScript journey.

The next time, we'll get back to types but for a very specific reason. Want to know more about? Then you just have to stick around for the next episode!
Up to then, happy coding and... see you next time! Cheers! 🤓

References

[1] If you are curious about mutation, you can find more about it on the official documentation

[2] This statement may be hard to understand by the given example. However, it is important to notice that loops in ReScript behaves like OCaml ones: aside not having any break mechanism (unless you want to consider to throw an exception), loops should always return a unit type or we get an error from the compiler. I think this may be worth noticing, since ReScript documentation doesn't mention that and this can lead to confusion rather easily.

[3] In fact, we can either start our program by invoking addOne first or multiplyByTwo. While this obviously affects the end result, our code is still valid syntactically.

[4] I want to personally thank the guys on Rescript Forum taking their time in helping me figuring out this difference. When I first read the documentation, I found this to be a little bit misleading in the way it is phrased. Hopefully, you can learn from my experience and have a better understanding than I had at a first glance.

[5] Given a function, in mathematics Domain refers to all of its valid inputs and Codomain refers to all of its possible outputs.

Comments

Popular posts from this blog