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.

Oldest comments (0)