Apr 17, 2023

Better interop with customizable variants

A tour of new capabilities coming in ReScript v11

ReScript Team
Core Development

ReScript v11 is around the corner, and it comes packed with new features that will improve interop with JavaScript/TypeScript. Recently we've made some changes to the runtime representation of variants that'll allow you to use variants for a large number of new interop scenarios, zero cost. This is important, because variants are the feature of ReScript, enabling great data modeling, pattern matching and more.

  • Customizable runtime representation. We're making the runtime representation of variants customizable. This will allow you to cleanly map variants to external data and APIs in many more cases than before.

  • Zero cost bindings to discriminated unions. Variants with inline records will map cleanly to JavaScript/TypeScript discriminated unions.

  • Unboxed (untagged) variants. We also introduce untagged variants - variants where the underlying runtime representation can be a primitive, without a specific discriminator. This will let you cleanly map to things like heterogenous array items, nullable values, and more.

Let's dive into the details.

Tagged variants

Variants with payloads have always been represented as a tagged union at runtime. Here's an example:

RESCRIPT
type entity = User({name: string}) | Group({workingName: string}) let user = User({name: "Hello"})

This is represented as:

JAVASCRIPT
var user = { TAG: /* User */ 0, name: "Hello", };

However, this has been problematic when binding to external data because there has been no way to customize the discriminator (the TAG property) or how its value is represented for each variant case (0 representing User here). This means that unless your external data is modeled the exact same way as above, which is unlikely, you'd be forced to convert to the structure ReScript expects at runtime.

To illustrate this, let's imagine we're binding to an external union that looks like this in TypeScript:

TYPESCRIPT
type LoadingState = | { state: "loading"; ready: boolean } | { state: "error"; message: string } | { state: "done"; data: Data };

Currently, there's no good way to use a ReScript variant to represent this type without resorting to manual and error-prone runtime conversion. However, with the new functionality, binding to the above with no additional runtime cost is easy:

RESCRIPT
@tag("state") type loadingState = | @as("loading") Loading({ready: bool}) | @as("error") Error({message: string}) | @as("done") Done({data: data}) let state = Error({message: "Something went wrong!"})

This will compile to:

JAVASCRIPT
var state = { state: "error", message: "Something went wrong!", };

Let's break down what we've done to make this work:

  • The @tag attribute lets you customize the discriminator (default: TAG). We're setting that to "state" so we map to what the external data looks like.

  • Each variant case has an @as attribute. That controls what each variant case is discriminated on (default: the variant case name as string). We're setting all of the cases to their lowercase equivalent, because that's what the external data looks like.

The end result is clean and zero cost bindings to the external data, in a way that previously would require manual runtime conversion.

Now, let's look at a few more real-world examples.

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;

Previously, you'd be forced to use a polymorphic variant for this if you wanted clean, zero-cost interop:

RESCRIPT
type direction = [#UP | #DOWN | #LEFT | #RIGHT] @module("./direction.js") external myDirection: direction = "myDirection"

Notice a few things:

  • We're forced to use the names of the enum payload, meaning it won't fully map to what you'd use in TypeScript

  • There's no way to bring over the documentation strings, because polymorphic variants are structural, so there's no one source definition for them to look for docstrings on. This is true even if you annotate with your explicitly written out polymorphic variant definition.

With the new runtime representation, this is how you'd bind to the above enum instead:

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.

With the new customizable variants, you could bind to the above string literal type easily, but add documentation, and change the name you interact with in ReScript. And there's no runtime cost.

Untagged variants

We've also implemented support for untagged variants. This will let you use variants to represent values that are primitives and literals in a way that hasn't been possible before.

We'll explain what this is and why it's useful by showing a number of real world examples. Let's start with a simple one on how we can now 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];

This was previously possible to do, leveraging a few tricks, when you didn't need to potentially read the values from the array again in ReScript. But, if you wanted to read back the values, you'd have to do a number of manual steps.

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

Let's look at a few more examples of what untagged variants enable.

Pattern matching on nullable values