Variant

So far, most of ReScript's data structures might look familiar to you. This section introduces an extremely important, and perhaps unfamiliar, data structure: variant.

Most data structures in most languages are about "this and that". A variant allows us to express "this or that".

ReScriptJS Output
type myResponse =
  | Yes
  | No
  | PrettyMuch

let areYouCrushingIt = Yes

myResponse is a variant type with the cases Yes, No and PrettyMuch, which are called "variant constructors" (or "variant tag"). The | bar separates each constructor.

Note: a variant's constructors need to be capitalized.

Variant Needs an Explicit Definition

If the variant you're using is in a different file, bring it into scope like you'd do for a record:

ReScriptJS Output
// Zoo.res
type animal = Dog | Cat | Bird
ReScriptJS Output
// Example.res
let pet: Zoo.animal = Dog // preferred
// or
let pet2 = Zoo.Dog

Constructor Arguments

A variant's constructors can hold extra data separated by comma.

ReScriptJS Output
type account =
  | None
  | Instagram(string)
  | Facebook(string, int)

Here, Instagram holds a string, and Facebook holds a string and an int. Usage:

ReScriptJS Output
let myAccount = Facebook("Josh", 26)
let friendAccount = Instagram("Jenny")

Labeled Variant Payloads (Inline Record)

If a variant payload has multiple fields, you can use a record-like syntax to label them for better readability:

ReScriptJS Output
type user =
  | Number(int)
  | Id({name: string, password: string})

let me = Id({name: "Joe", password: "123"})

This is technically called an "inline record", and only allowed within a variant constructor. You cannot inline a record type declaration anywhere else in ReScript.

Of course, you can just put a regular record type in a variant too:

ReScriptJS Output
type u = {name: string, password: string}
type user =
  | Number(int)
  | Id(u)

let me = Id({name: "Joe", password: "123"})

The output is slightly uglier and less performant than the former.

Pattern Matching On Variant

See the Pattern Matching/Destructuring section later.

JavaScript Output

A variant value compiles to 3 possible JavaScript outputs depending on its type declaration:

  • If the variant value is a constructor with no payload, it compiles to a string.

  • If it's a constructor with a payload, it compiles to an object with the field TAG and the field _0 for the first payload, _1 for the second payload, etc.

  • An exception to the above is a variant whose type declaration contains only a single constructor with payload. In that case, the constructor compiles to an object without the TAG field.

  • Labeled variant payloads (the inline record trick earlier) compile to an object with the label names instead of _0, _1, etc. The object might or might not have the TAG field as per previous rule.

Check the output in these examples:

ReScriptJS Output
type greeting = Hello | Goodbye
let g1 = Hello
let g2 = Goodbye

type outcome = Good | Error(string)
let o1 = Good
let o2 = Error("oops!")

type family = Child | Mom(int, string) | Dad (int)
let f1 = Child
let f2 = Mom(30, "Jane")
let f3 = Dad(32)

type person = Teacher | Student({gpa: float})
let p1 = Teacher
let p2 = Student({gpa: 99.5})

type s = {score: float}
type adventurer = Warrior(s) | Wizard(string)
let a1 = Warrior({score: 10.5})
let a2 = Wizard("Joe")

Tagged variants

  • The @tag attribute lets you customize the discriminator (default: TAG).

  • @as attributes control what each variant case is discriminated on (default: the variant case name as string).

Example: Binding to TypeScript enums

TYPESCRIPT
// direction.ts /** Direction of the action. */ enum Direction { /** The direction is up. */ Up = "UP", /** The direction is down. */ Down = "DOWN", /** The direction is left. */ Left = "LEFT", /** The direction is right. */ Right = "RIGHT", } export const myDirection = Direction.Up;

You can bind to the above enums like so:

RESCRIPT
/** Direction of the action. */ type direction = | /** The direction is up. */ @as("UP") Up | /** The direction is down. */ @as("DOWN") Down | /** The direction is left. */ @as("LEFT") Left | /** The direction is right. */ @as("RIGHT") Right @module("./direction.js") external myDirection: direction = "myDirection"

Now, this maps 100% to the TypeScript code, including letting us bring over the documentation strings so we get a nice editor experience.

String literals

The same logic is easily applied to string literals from TypeScript, only here the benefit is even larger, because string literals have the same limitations in TypeScript that polymorphic variants have in ReScript.

TYPESCRIPT
// direction.ts type direction = "UP" | "DOWN" | "LEFT" | "RIGHT";

There's no way to attach documentation strings to string literals in TypeScript, and you only get the actual value to interact with.

Untagged variants

With untagged variants it is possible to represent a heterogenous array.

RESCRIPT
@unboxed type listItemValue = String(string) | Boolean(bool) | Number(float) let myArray = [String("Hello"), Boolean(true), Boolean(false), Number(13.37)]

Here, each value will be unboxed at runtime. That means that the variant payload will be all that's left, the variant case name wrapping the payload itself will be stripped out and the payload will be all that remains.

It, therefore, compiles to this JS:

JAVASCRIPT
var myArray = ["hello", true, false, 13.37];

In the above example, reaching back into the values is as simple as pattern matching on them.

Pattern matching on nullable values

RESCRIPT
// The type definition below is inlined here to examplify, but this definition will live in [Core](https://github.com/rescript-association/rescript-core) and be easily accessible module Null = { @unboxed type t<'a> = Present('a) | @as(null) Null } type userAge = {ageNum: Null.t<int>} type rec user = { name: string, age: Null.t<userAge>, bestFriend: Null.t<user>, } let getBestFriendsAge = user => switch user.bestFriend { | Present({age: Present({ageNum: Present(ageNum)})}) => Some(ageNum) | _ => None }

Notice how @as allows us to say that an untagged variant case should map to a specific underlying primitive. Present has a type variable, so it can hold any type. And since it's an unboxed type, only the payloads 'a or null will be kept at runtime. That's where the magic comes from.

Decoding and encoding JSON idiomatically

With untagged variants, we have everything we need to define a JSON type:

RESCRIPT
@unboxed type rec json = | @as(null) Null | Boolean(bool) | String(string) | Number(float) | Object(Js.Dict.t<json>) | Array(array<json>) let myValidJsonValue = Array([String("Hi"), Number(123.)])

Here's an example of how you could write your own JSON decoders easily using the above, leveraging pattern matching:

RESCRIPT
@unboxed type rec json = | @as(false) False | @as(true) True | @as(null) Null | String(string) | Number(float) | Object(Js.Dict.t<json>) | Array(array<json>) type rec user = { name: string, age: int, bestFriend: option<user>, } let rec decodeUser = json => switch json { | Object(userDict) => switch ( userDict->Dict.get("name"), userDict->Dict.get("age"), userDict->Dict.get("bestFriend"), ) { | (Some(String(name)), Some(Number(age)), Some(maybeBestFriend)) => Some({ name, age: age->Float.toInt, bestFriend: maybeBestFriend->decodeUser, }) | _ => None } | _ => None } let decodeUsers = json => switch json { | Array(array) => array->Array.map(decodeUser)->Array.keepSome | _ => [] }

Encoding that same structure back into JSON is also easy:

RESCRIPT
let rec userToJson = user => Object( Dict.fromArray([ ("name", String(user.name)), ("age", Number(user.age->Int.toFloat)), ( "bestFriend", switch user.bestFriend { | None => Null | Some(friend) => userToJson(friend) }, ), ]), ) let usersToJson = users => Array(users->Array.map(userToJson))

This can be extrapolated to many more cases.

// ### Unboxable types

// TODO #734: Add a list of what can currently be unboxed (and why), and a note that it's possible that more things could be unboxed in the future.

// ### Catch all

// TODO #733: Add a small section on the "catch all" trick, and what kind of things that enable.

// ## Variant spread

// TODO #732

Coercion

You can convert a variant to a string or int at no cost:

ReScriptJS Output
type company = Apple | Facebook | Other(string)
let theCompany: company = Apple

let message = "Hello " ++ (theCompany :> string)

// TODO #731: expand this section with: // // Coercing between variants (and the constraints around that) // Why you can sometimes coerce from variant to string/int/float, and how to think about that (runtime representation must match) // The last additions of allowing coercing strings to unboxed variants with catch-all string cases

Tips & Tricks

Be careful not to confuse a constructor carrying 2 arguments with a constructor carrying a single tuple argument:

ReScriptJS Output
type account =
  | Facebook(string, int) // 2 arguments
type account2 =
  | Instagram((string, int)) // 1 argument - happens to be a 2-tuple

Variants Must Have Constructors

If you come from an untyped language, you might be tempted to try type myType = int | string. This isn't possible in ReScript; you'd have to give each branch a constructor: type myType = Int(int) | String(string). The former looks nice, but causes lots of trouble down the line.

Interop with JavaScript

This section assumes knowledge about our JavaScript interop. Skip this if you haven't felt the itch to use variants for wrapping JS functions yet.

Quite a few JS libraries use functions that can accept many types of arguments. In these cases, it's very tempting to model them as variants. For example, suppose there's a myLibrary.draw JS function that takes in either a number or a string. You might be tempted to bind it like so:

ReScriptJS Output
// reserved for internal usage
@module("myLibrary") external draw : 'a => unit = "draw"

type animal =
  | MyFloat(float)
  | MyString(string)

let betterDraw = (animal) =>
  switch animal {
  | MyFloat(f) => draw(f)
  | MyString(s) => draw(s)
  }

betterDraw(MyFloat(1.5))

Try not to do that, as this generates extra noisy output. Instead, use the @unboxed attribute to guide ReScript to generate more efficient code:

ReScriptJS Output
// reserved for internal usage
@module("myLibrary") external draw : 'a => unit = "draw"

@unboxed
type animal =
  | MyFloat(float)
  | MyString(string)

let betterDraw = (animal) =>
  switch animal {
  | MyFloat(f) => draw(f)
  | MyString(s) => draw(s)
  }

betterDraw(MyFloat(1.5))

Alternatively, define two externals that both compile to the same JS call:

ReScriptJS Output
@module("myLibrary") external drawFloat: float => unit = "draw"
@module("myLibrary") external drawString: string => unit = "draw"

ReScript also provides a few other ways to do this.

Variant Types Are Found By Field Name

Please refer to this record section. Variants are the same: a function can't accept an arbitrary constructor shared by two different variants. Again, such feature exists; it's called a polymorphic variant. We'll talk about this in the future =).

Design Decisions

Variants, in their many forms (polymorphic variant, open variant, GADT, etc.), are likely the feature of a type system such as ReScript's. The aforementioned option variant, for example, obliterates the need for nullable types, a major source of bugs in other languages. Philosophically speaking, a problem is composed of many possible branches/conditions. Mishandling these conditions is the majority of what we call bugs. A type system doesn't magically eliminate bugs; it points out the unhandled conditions and asks you to cover them*. The ability to model "this or that" correctly is crucial.

For example, some folks wonder how the type system can safely eliminate badly formatted JSON data from propagating into their program. They don't, not by themselves! But if the parser returns the option type None | Some(actualData), then you'd have to handle the None case explicitly in later call sites. That's all there is.

Performance-wise, a variant can potentially tremendously speed up your program's logic. Here's a piece of JavaScript:

JS
let data = 'dog' if (data === 'dog') { ... } else if (data === 'cat') { ... } else if (data === 'bird') { ... }

There's a linear amount of branch checking here (O(n)). Compare this to using a ReScript variant:

ReScriptJS Output
type animal = Dog | Cat | Bird
let data = Dog
switch data {
| Dog => Js.log("Wof")
| Cat => Js.log("Meow")
| Bird => Js.log("Kashiiin")
}

The compiler sees the variant, then

  1. conceptually turns them into type animal = 0 | 1 | 2

  2. compiles switch to a constant-time jump table (O(1)).