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.
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
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:
// 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:
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
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:
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 insideasync
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
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
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
// 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
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! 🤓
Comments
Post a Comment