In this chapter, you will learn how to write and use functions in ReScript, and discuss topics such as recursion, pipes, and polymorphism. We’ll also introduce functional programming concepts like purity and using functions as values.
Defining and Using Functions
ReScript’s function definition syntax is very similar to the syntax for anonymous functions in JavaScript. The body of a function is a single expression or block, and the result of evaluating the body is the value that is returned from the function.
In ReScript, function definitions are values. As such, they can be bound to names using let bindings just like any other value, and they can be called using that name later.
Functions can be declared anywhere, including inside other functions, and the visibility of these declarations follows the same scoping rules as other let bindings.
Type Annotations
Like other values in ReScript, functions have types as well. The type of a function (also called its type signature) consists of a list of types for each parameter, followed by the type of the value returned from the function.
Function type signatures can be expressed in the form (input1, input2, ... inputN) => output. For example, the function (x, y) => x + y is also a value of type (int, int) => int. It can only be called with one or two arguments of type int, and cannot be called with anything else. That is not a typo – we can call a function that has two parameters with only a single argument. More on that later!
Adding type annotations has two benefits: the first is to make the code clearer to other people who may be reading it, and the second is to add additional restrictions the possible values a function may be called with.
Using Standard Library Functions and Opening Modules
In the examples from the last chapter, you saw standard library functions like Belt.Int.toString and Belt.String.trim. The standard library is organized into modules, which are groupings of values, types, and functions. We’ll go into more details about modules in a later chapter – for now, you just need to know enough to use the standard library.
Module members can be accessed using the dot notation: for example, Belt.Int.toString refers to the toString function defined inside the module called Int, which is nested inside the module called Belt.
Higher-Order Functions
Since functions are values, they can also be passed as arguments into other functions. Simple functions that operate on regular data types like strings and integers and arrays are first-order functions, while a function that takes another function as input is called a higher-order function. Using higher-order functions is one aspect of functional programming called higher-order programming.
One difference you’ll notice between the JavaScript and ReScript examples earlier is that the JavaScript API is a method call on the array, while the ReScript API is a function that takes in the array as an input.
In fact, the concept of classes and instance methods does not exist in ReScript at all! One purpose of classes in object-oriented languages is to define data types and the permitted operations on those data types.
In ReScript, complex data types can be defined without needing a class, and modules let the programmer associate a data type with its supported operations. For example, the standard library module Js.Array2 contains functions for working with arrays.
As you learn more about the standard library, you’ll probably get the feeling that using modules is a bit like using classes with only static methods, but that’s not the whole picture. Modules are actually much more powerful than that, and you’ll learn all about them in a later chapter.
Piping
The pipe operator (->) takes the result of the expression on the left and passes it as an argument to the function on the right. The argument piped from the left corresponds to the first parameter of the function being called. Subsequent arguments are wrapped in parentheses like a regular function call.
For example, the expression arr->Js.Array2.map(x => x * 2) is equivalent to Js.Array2.map(arr, x => x * 2).
The pipe is a very powerful piece of syntax because it lets us compose and chain functions in a way that is easy to read.
With piping, reading the code from left to right or top to bottom matches the flow of the data, whereas nested function call expressions have to be read from inside out.
For example, writing an expression like 100->double->double->double makes far more sense than writing something like double(double(double(100))).
Since we can only pipe to the first argument in a function, functions where the first parameter is the same type as the output are easier to compose using pipes. However, not every function needs to be written to optimize for piping – only when it actually makes sense to compose functions together.
Labeled and Optional Parameters
In JavaScript, function parameters are positional parameters – when a function is called, the first argument corresponds to the first parameter, and the second argument to the second parameter, etc.
Labeling parameters is useful for making call sites clearer, but if the parameter names get too long, we can use as to make a shorter alias to refer to them with a different name inside the function body.
Currying and Partial Application
Currying is a feature in functional languages that allows a function to be treated as a sequence of nested functions that take a single argument each. For example, a function with two arguments is no different from a function that takes in the first argument and outputs another function, which takes in the second argument and outputs the final result.
Functions in ReScript are curried by default, which means that they support partial application – we can call them without providing all the arguments.
It is important to note that partially applying a function will not execute the function body. The function body is only executed when all the arguments are applied, and a partially applied function with only one argument missing can be reused like any other function with one parameter.
Partial application is kind of neat to think about and unlocks some flexibility in how we use functions, but it should be used very sparingly. In most cases it doesn’t add to expressiveness, and it has a runtime performance penalty.
Polymorphic Functions
ReScript supports polymorphism in functions and types. This means that type signatures are not restricted to only matching a single concrete type, and the same function can be used on multiple types of arguments. For example, we can write a function that can be called on any type of array regardless of what contents it holds.
In type signatures, polymorphic types are represented by type variables with arbitrary names prefixed with an apostrophe, like 'a, 'b, and 'c.
This simple function that checks for structural equality between two values (x, y) => x == y has the signature ('a, 'a) => bool. It is polymorphic because we can pass in values of any type to the function, as long as both arguments have the same type (note that both arguments in the type signature are annotated with the same type name, 'a).
The logging function Js.log2 has the signature ('a, 'b) => unit. Unlike the previous example, the arguments to Js.log2 can be different types since they have different type variables.
Polymorphism is important and useful, but you usually don’t need to think about it too hard when programming. The compiler can automatically infer the types of polymorphic functions, and using polymorphic functions is the same as using regular functions.
One place where we’ll encounter many polymorphic functions is the standard library for container data types. Many operations on containers like arrays are polymorphic because they do not care about the type of contents the container holds. We’ll go into depth on containers and collections later, but for now we’ll just look at Belt.Array.getExn as an example.
The Belt.Array.getExn function takes an array and an index, and returns the value at that index. It has the signature (array<'a>, int) => 'a. The name 'a is used as a type parameter for the array type, and it’s also used as the type of the returned value. This means that the type that is returned from the function is the same type as the contents of the array that the function is called with.
Another important use for polymorphism is binding to JavaScript functions, many of which are polymorphic due to JavaScript’s dynamic nature. We’ll discuss JavaScript bindings in more detail in a later chapter.
Pure Functions
Recall the discussion in the previous chapter about expressions and side effects – a function call is an expression, and like every other expression they evaluate to a single value and may have possible side effects. There is a concept called purity in functional programming which may be used to classify functions.
When a function does not depend on any external state besides its inputs and evaluating it has no side effects, then it is considered a pure function. Pure functions have the nice property that every time we evaluate it with the same inputs, we know it will yield the exact same results.
On the other hand, functions that have side effects or depend on external state are considered impure functions. A good example of this is a function that adds a row to a database and returns the number of rows in the database. That function depends on external state (the contents of the database) and also mutates that state. If we call it with the same arguments multiple times in a row, it will yield different results each time.
While it’s impossible to write real-world software with only pure functions, there are good reasons to try to keep side effects under control. Pure functions are easier to reason about because their behavior is consistent, and they are easier to test as well – no need to set up external states and dependencies, just call the function with the inputs we want to test. We don’t need functions to be perfectly pure, but in general the fewer side effects and dependencies on global state a function has, the easier it is to test and maintain.
In JavaScript, there’s very little to stop an undisciplined programmer from writing code that has tons of side effects and is a nightmare to test. Purity is not built into ReScript either, in the sense that it’s not possible to mark or infer a ReScript function as pure or impure.
In ReScript, immutability is the default and mutation generally needs to be explicit. This means there are fewer ways to interact with global state, and as you’ll learn later, there are patterns of programming which can help avoid exceptions. In this way, ReScript makes it easier to keep side effects relatively minimal while still allowing the programmer to write stateful code as needed.
Although the ideas of side effects and purity aren’t explicitly language features in ReScript, the reason I introduce this concept here is because it’s a useful way to think about functions. Keeping this in mind going forward will help you think about software differently and write cleaner and more testable code.
Ignoring Return Values
In ReScript, values have to be used or ignored explicitly. If we want to perform a computation solely for its side effects and ignore the return value, one way to do it is to pass the value to the ignore function, which throws away the result of the computation wrapped inside and evaluates to ().
As shown in the example, this is useful when we are calling a function and only care about the side effects, but not the value that it returns. If we wrap a pure function with ignore, it effectively wastes the entire computation, because no state is changed and the returned value is thrown away.
Recursion
Syntax
What if we want to write two mutually recursive functions (where the first function calls the second and vice versa)?
To declare mutually recursive functions, we can declare both functions with the same let binding, separated by and. The keywords let and rec do not need to be repeated for the second declaration.
How to Use Recursion
Recursion is a very powerful and versatile tool in a programmer’s tool belt. In ReScript, higher-order functions and recursion can help simplify logic that would otherwise need a for-loop or while-loop. Recursion is also a natural choice when traversing data types with a recursive structure, such as linked lists and trees (more on that in the next chapter).
Let’s say we were writing a function to calculate the factorial of a number. If we’re programming in an imperative style, we might implement it using iteration and mutable state. If we’re programming in a functional style, we could implement it using recursion. I’ll present four examples of the factorial function, implemented iteratively and recursively in both ReScript and JavaScript.
In many cases, using recursion can help us write cleaner and safer code compared to using loops and mutable state.
When expressing complex logic in a loop, we may end up tracking and updating many different variables outside the loop – collecting results, marking and checking various boolean flags, etc. With a recursive implementation, a lot of that mental overhead is eliminated – not having to worry about the state outside of the loop body means that all we need to think about are the inputs to our recursive function and what the stopping condition is.
However, there are some situations when we would want to choose loops over recursion for performance reasons. Deeply nested recursion (think thousands of recursive calls) have worse performance than loops, and in extreme cases can lead to the program crashing when it hits the call stack limit in the JavaScript runtime – try calling the isEven or isOdd example on max_int!
It’s important to emphasize that unrolling happens automatically, and it doesn’t work for very complex cases. We can easily inspect the compiled output to see whether or not a recursive function was unrolled, but we cannot force the compiler to unroll a particular function. For complex and performance-sensitive computations, the only way to guarantee that our code will be compiled into a loop is to write it as a loop in the first place.
In general, the aforementioned drawbacks only come into play when writing software that is performance-sensitive or needs to handle extreme inputs. In most everyday use cases, recursion is a very useful technique for simplifying your programs.
Final Thoughts
At the syntax level, ReScript’s functions should look and feel familiar to anyone with experience in JavaScript. However, ReScript offers a lot more beyond what is possible in JavaScript. Features like piping unlock a lot of flexibility in how we use functions and help keep our code clean and readable.
In this chapter we also introduced the concept of purity as a way to classify functions. While it’s impossible to completely eliminate side effects from any real software application, keeping these ideas in mind can help us write code that is more self-contained and therefore easier to understand and test.
With a grasp of how functions work in ReScript, we can now begin to write more interesting programs. Until now we’ve mostly worked with basic data types like integers and booleans and strings. In the next few chapters, we’ll discuss more complex data types built into the language, and how we can define our own custom data types.