Tales of a Sardinian software engineer - Functional Friday Episode 3 - Learning Rescript - How to declare and use a function in Rescript, optional arguments and currying

Functional Friday - Episode 3

Learning Rescript - Introducing functions


Welcome back to Functional Friday, the place where a function didn't return because someone forgot to call her back 🥁! In the last episode we learnt how important expressions are in ReScript and how to use them at a very basic level, including how to define and use if expressions.

Keeping up with this topic, in this week episode we'll see another form of expressions: functions. If you're here for the first time, I highly suggest you to read at least the second episode so you don't get lost too much (hopefully 😅).
As a disclaimer, this episode will be quite technical hence more dense than previous ones; this means that you may find it harder to digest it as a whole: if this is the case, please take your time to get through it little by little.
We're here for learning new things, without any rush.


3.1 Function declaration

As said while talking about expressions, declaring a new function in ReScript looks really familiar for anyone having experience in JavaScript/TypeScript. In fact, you can declare a new function by writing

RES
// as lambda (or anonymous)
x => x + 1

// as named
let addOne = x => x + 1

a syntax very similar to the one we use in Javascript/Typescript when declaring an arrow function. However, it is interesting to notice how no further annotation is required, while writing the same function in TypeScript will look like

TS
const addOne = (x: number) => x + 1

which honestly is not a big a deal (not for such simple function anyway), but there's more to consider: the following code is perfectly fine at compile time, but it may be problematic to handle at runtime.

TS
const addOne = (x: number) => x + 1;

console.log(addOne(parseInt("Dayum"))); // This prints out as "NaN"

On the other hand, writing the same code in ReScript

RES
let addOne = x => x + 1

Js.log(addOne(Belt.Int.fromString("Dayum")))

will result in a type error

SH
This has type: option<int>
  Somewhere wanted: int

with the compiler warning us about the usage of a potentially unsafe cast operation, even if when dealing with an explicit one [1]. This will force us to properly handle all of the edge cases, something that I personally like [2].

Also, functions being expressions means that we can use them whenever we can use an expression, for example we can mix let expressions and lambda functions together

RES
// Quiz time: What are resulting type and value for b?
let a = 5

let b = a + (val => val + 1)(5)

Once again, reading left to right may be useful to define type and value of this last expression. Value 5 is passed as input to a lambda function which adds to 1 its input argument, so 5 + 1 = 6. Now, we just have to add a value, which is 5 to 6.
So b is 5 + 6 = 11, and has type int.


3.2 Labeled Arguments

When dealing with multi-arguments functions, it may be useful to refer them by name instead of relying on their position. This kind of functionality is peculiar to ReScript and goes with the name of labeled arguments.
In order to use labeled arguments, we just prefix each variable name with the ~ symbol.

RES
let difference = (~minuend, ~subtrahend) => minuend - subtrahend

// No matter the order, this will be evaluated as 5 - 4
let res = difference(~subtrahend = 4, ~minuend = 5);

Unfortunately in JavaScript/TypeScript we don't really have an equivalent syntax. However, we can emulate a similar behavior by using object literals

TS
type DifferenceArgs = {
  minuend: number;
  subtrahend: number;
}

const difference = ({ minuend, subtrahend }: DifferenceArgs) => minuend - subtrahend;

const res = difference({ subtrahend: 4, minuend: 5 });

but again, the type system cannot infer the proper types so we have to declare them explicitly, otherwise minuend and subtrahend will be evaluated as any, defying the goal of having a type checker in place.

3.2.1 Optional labeled arguments

Labeled arguments may as well be defined as optional, with different syntaxes for:

  • parameters that can be omitted;
  • parameters that have a default value;
RES
// Optional, with default value
let divide = (~divisor = 1, dividend) => dividend / divisor

let res = divide(5) // This will be evaluated as 5

// Optional, without default value
let getBreakfast = (~biscuits=?, drink) => 
  if biscuits === Some(true) { `${drink} with biscuits` }
  else { `${drink}` }

let breakfast = getBreakfast("Milk") // This will be evaluated as "Milk"

Now, this is a really important note to keep in mind. Considering the following example

RES
// All parameters are optional
let divide = (~divisor = 1, ~dividend=0, ()) => dividend / divisor

// So this will evaluate into 0
let resWithNoParams = divide()
// ...but what about this?
let resWithSingleParam = divide(~dividend=5)

what do we expect resWithSingleParam to evaluate into? Let's figure that out in the next section.


3.3 Implicit currying

While still referring to our last example, we may be surprised that resWithSingleParam is evaluated as [Function: resWithSingleParam] which means that is a function. Confused? Good, so was I.
This happens because ReScript functions are curried by default which is a fancy way to say that each function can take exactly one argument. So writing let fun = (x, y) => x + y is equivalent to let fun = x => (y => x + y) or, more in general

RES
let fun = (arg1, arg2, ..., argN) => e

is semantically equivalent to

RES
let fun = arg1 => 
  (curr1(arg2) =>
    (...
      (currN(argN) => e)
    )
  )

This tell us that whenever we write a function that takes n parameters, we're just writing a list of n functions and each one of them takes exactly one argument and returns a function.

Having such a built-in mechanism makes the language really expressive and powerful, but comes with a big (potential) drawback: performances. Even if the compiler tries to optimize the transpiled code and remove unnecessary currying sometimes we just need to be sure that a certain function gets executed as uncurried in order to keep our code as performant as we can.

In such cases, we just put a dot before starting the parameter list in both function declaration and subsequent calls.

RES
let uncurriedFn = (. arg1, ..., argN) => ...

// Note: both call and declaration requires the uncurry annotation.
let res = uncurried(. arg1, ..., argN)
            

It may be worth noting that uncurry notation and optional parameters are mutually exclusive, at least for now. Simply put, we either declare an uncurried function or we use optional parameters but any attempt to mix the two syntaxes will cause the compiler to throw the following error

SH
Uncurried function doesn't support optional arguments yet

While the aforementioned considerations are something definitely to keep in mind and are effectively drawbacks, it's worth considering that we shouldn't really exceed with the number of parameters nor abusing optional parameters anyway. If anything, these limitations may help us in thinking a little bit more about the code we're writing instead of using a feature just because we are allowed to do so (I'm looking at you, object destructuring 🫵).


3.4 Let's wrap it up! 🫡

First thing first, thanks for reading through the whole article: I really hope you enjoyed the content.
In this episode we saw that, despite sharing a similar syntax, ReScript functions have their own set of rules and features that set them apart from JavaScript/TypeScript ones.
We're not done with functions yet, but this episode was meant to provide a solid foundation about them. In the incoming one we'll see some more advanced concepts, including a tiny but incredibly useful operator to compose functions. If you're curious and want to know more about it, then stick around for the next episode. Up to then, happy coding and... see you next time! Cheers! 🤓

References

[1] During the Episode 2 we talked about the ( + ) operator not being polymorphic. This, paired with the Hindley-Milner type system, allows ReScript compiler to infer the correct type without any annotation. With that being said, if you're really curious about it, here you can find a good description about how type inference in OCaml (hence in ReScript) works.

[2] For the sake of simplicity, we'll consider operation safety comparing each language in its own environment. If we open up to interoperability with JavaScript, both ReScript and TypeScript come with their own set of challenges that will probably take a series on its own to explore and discuss about.

Comments

  1. Amazing post! Thanks for it and keep it up!

    ReplyDelete
    Replies
    1. Thanks for the kind words and for reading! 🙏

      Delete

Post a Comment

Popular posts from this blog