DEV Community

J David Eisenberg
J David Eisenberg

Posted on

Type Annotation in ReasonML

One of the most powerful features of ReasonML is its type inference system. In code like this:

let age = 42;
let price = 10.66;
let word = "reason";
let isValid = true;
let hours = [10, 2, 4];
let focalLength = (objDist, imgDist) => {
  (objDist *. imgDist) /. (objDist +. imgDist);
};
Enter fullscreen mode Exit fullscreen mode

ReasonML figures out the type of each of these bindings. If you’re using an editor like Visual Studio Code with the reason-vscode extension, you can see what ReasonML has inferred:

image showing int, float, string, bool, list(int), and function types for the bindings

The type inference system does such a good job of figuring out what types you’re using that you can write an entire program without having to specify any types. But type inference is still there, looking over your shoulder and letting you know if you do something wrong, as in line two:

  We've found a bug for you!
  /home/david/annotation/src/Demo.re 2:19-21

  1 │ let age = 42;
  2 │ let total = age + "3";
  3 │ let price = 10.66;
  4 │ let word = "reason";

  This has type:
    string
  But somewhere wanted:
    int
Enter fullscreen mode Exit fullscreen mode

Sometimes, though, there are situations where the type inference system can’t figure out what you need. Sometimes there’s an ambiguous situation (two different record types with the same fields or similar ones in different modules) where type inference makes a choice—but not the one you want. And sometimes you just plain want to do your own type annotation. Here’s how to do it.

Annotating Value Bindings

For bindings to a value, you follow the variable name with a colon and the value’s type. Here are our original value bindings with explicit annotation:

let age: int = 42;
let price: float = 10.66;
let word: string = "reason";
let isValid: bool = true;
let hours: list(int) = [10, 2, 4];
Enter fullscreen mode Exit fullscreen mode

While you can annotate value bindings, almost nobody does this. In most cases, the expression on the right-hand side makes the type sufficiently clear that adding the annotation won’t give you an exponential gain in clarity.

However, many people do annotate function bindings.

Annotating Function Bindings (Method 1)

Function binding annotation follows the same pattern, with the type information between the function name and the equal sign:

  • Start with a colon
  • In parentheses, specify the parameter types
  • Add =>
  • Specify the return type

Here’s the annotation for the focal length function:

let focalLength: (float, float) => float  =
  (objDist, imgDist) => {
    (objDist *. imgDist) /. (objDist +. imgDist);
  };  
Enter fullscreen mode Exit fullscreen mode

Annotating Function Bindings (Method 2)

The preceding method with the type information separate from the parameter list and function body is familiar to people coming from a language like Haskell or Elm.

If you’re coming from a language like Java or TypeScript or Flow, you’re used to seeing type information attached each individual parameter. ReasonML supports that kind of notation as well:

let focalLength = (objDist: float, imgDist: float): float => {
    (objDist *. imgDist) /. (objDist +. imgDist);
  };  
Enter fullscreen mode Exit fullscreen mode

Annotating Functions with Labeled Parameters

Consider this un-annotated function to calculate the total price, given a quantity, unit price, and tax as a percent. This function uses labeled parameters, specified with the ~. When you call the function, you need to give the parameter name, but you can give the parameters in any order you like:

let totalPrice = (~qty, ~unitPrice, ~tax) => {
   (float_of_int(qty) *. unitPrice) *. (1.0 +. (tax /. 100.0));
};

let price1 = totalPrice(~qty=5, ~unitPrice=34.95, ~tax=7.5);
let price2 = totalPrice(~unitPrice=15.00, ~tax=5.0, ~qty=12);
Enter fullscreen mode Exit fullscreen mode

When you annotate this function with the type information separated from the function definition (method 1), you need to name the parameters in the same order as in their declaration in the function:

let totalPrice: (~qty: int, ~unitPrice: float, ~tax: float) => float =
  (~qty, ~unitPrice, ~tax) => {
    (float_of_int(qty) *. unitPrice) *. (1.0 +. (tax /. 100.0));
  };
Enter fullscreen mode Exit fullscreen mode

When using parameters-with-their-types (method 2), add the type information exactly as you did with unlabeled parameters.

let totalPrice =  (~qty: int, ~unitPrice: float, ~tax:float): float => {
   (float_of_int(qty) *. unitPrice) *. (1.0 +. (tax /. 100.0));
};
Enter fullscreen mode Exit fullscreen mode

Which Method to Use?

The main advantage of the separate specification (method 1) is that this is the format you use when creating a .rei (ReasonML interface) file. You use .rei files to specify an API for modules that you would like other people to use.

The main advantages of the parameter-with-type specification (method 2) are familiarity and the fact that you don’t have to specify a type for every parameter. If a parameter’s type is too complicated for you to figure out, you can leave it out and let the type inference system take over for you.

From what I’ve seen in code written by others, most people let the inference system do all the work. When they do annotate, they use method 2.

Should You Annotate?

If you’re coming from a programming language where you have to specify types, I recommend that you annotate your ReasonML code as well.

If you’re coming from an untyped language, I recommend that you annotate your code as you’re learning ReasonML. This will get you used to thinking through exactly what kinds of input and output your functions need. Don’t worry about making mistakes—the type inference system will keep you honest!

Please let me know your thoughts in the discussion.

Top comments (1)

Collapse
 
johnridesabike profile image
John Jackson • Edited

When I’m developing new code, I usually start by annotating functions as much as I can. Without annotations, it’s possible for the type inference to work against you. Suppose you write a function that you think should return one type, but the compiler infers that it returns a different type. The error would be inside the function (causing it to return the type you don’t want) but the error won’t be reported until later when you use the function somewhere else. The compiler isn’t “wrong,” but it can’t necessarily tell you which part of the code needs to be fixed, just where the error occured. By annotating the functions beforehand, you will be able to catch the error early on.

But once code becomes stable, I usually remove the annotations. They seem redundant when the compiler can display them for you in your IDE.