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".
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:
Constructor Arguments
A variant's constructors can hold extra data separated by comma.
Here, Instagram
holds a string
, and Facebook
holds a string
and an int
. Usage:
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:
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:
The output is slightly uglier and less performant than the former.
Variant Type Spreads
Just like with records, it's possible to use type spreads to create new variants from other variants:
RESCRIPTtype a = One | Two | Three
type b = | ...a | Four | Five
Type b
is now:
RESCRIPTtype b = One | Two | Three | Four | Five
Type spreads act as a 'copy-paste', meaning all constructors are copied as-is from a
to b
. Here are the rules for spreads to work:
You can't overwrite constructors, so the same constructor name can exist in only one place as you spread. This is true even if the constructors are identical.
All variants and constructors must share the same runtime configuration -
@unboxed
,@tag
,@as
and so on.You can't spread types in recursive definitions.
Note that you need a leading |
if you want to use a spread in the first position of a variant definition.
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 of the constructor name. Example:
Yes
compiles to"Yes"
.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. The value ofTAG
is the constructor name as string by default, but note that the name of theTAG
field as well as the string value used for each constructor name can be customized.Labeled variant payloads (the inline record trick earlier) compile to an object with the label names instead of
_0
,_1
, etc. The object will have theTAG
field as per the previous rule.
Check the output in these examples:
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.
Valid @as
payloads
Here's a list of everything you can put in the @as
tag of a variant constructor:
A string literal:
@as("success")
An int:
@as(5)
A float:
@as(1.5)
True/false:
@as(true)
and@as(false)
Null:
@as(null)
Undefined:
@as(undefined)
Untagged variants
With untagged variants it is possible to mix types together that normally can't be mixed in the ReScript type system, as long as there's a way to discriminate them at runtime. For example, with untagged variants you can 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:
JAVASCRIPTvar myArray = ["hello", true, false, 13.37];
In the above example, reaching back into the values is as simple as pattern matching on them.
Advanced: Unboxing rules
No overlap in constructors
A variant can be unboxed if no constructors have overlap in their runtime representation.
For example, you can't have String1(string) | String2(string)
in the same unboxed variant, because there's no way for ReScript to know at runtime which of String1
or String2
that string
belongs to, as it could belong to both.
The same goes for two records - even if they have fully different shapes, they're still JavaScript object
at runtime.
Don't worry - the compiler will guide you and ensure there's no overlap.
What you can unbox
Here's a list of all possible things you can unbox:
string
:String(string)
float
:Number(float)
. Noticeint
cannot be unboxed, because JavaScript only hasnumber
(not actuallyint
andfloat
like in ReScript) so we can't disambiguate betweenfloat
andint
at runtime.bool
:Boolean(bool)
array<'value>
:List(array<string>)
promise<'value>
:Promise(promise<string>)
Dict.t
:Object(Dict.t<string>)
Date.t
:Date(Date.t)
. A JavaScript date.Blob.t
:Blob(Blob.t)
. A JavaScript blob.File.t
:File(File.t)
. A JavaScript file.RegExp.t
:RegExp(RegExp.t)
. A JavaScript regexp instance.
Again notice that the constructor names can be anything, what matters is what's in the payload.
Under the hood: Untagged variants uses a combination of JavaScript
typeof
andinstanceof
checks to discern between unboxed constructors at runtime. This means that we could add more things to the list above detailing what can be unboxed, if there are useful enough use cases.
Pattern matching on unboxed variants
Pattern matching works the same on unboxed variants as it does on regular variants. In fact, in the perspective of ReScript's type system there's no difference between untagged and tagged variants. You can do virtually the same things with both. That's the beauty of untagged variants - they're just variants to you as a developer.
Here's an example of pattern matching on an unboxed nullable value that illustrates the above:
RESCRIPTmodule 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
}
No difference to how you'd do with a regular variant. But, the runtime representation is different to a regular variant.
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
ornull
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 native 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(null) Null
| Boolean(bool)
| 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:
RESCRIPTlet 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.
Advanced: Catch-all Constructors
With untagged variants comes a rather interesting capability - catch-all cases are now possible to encode directly into a variant.
Let's look at how it works. Imagine you're using a third party API that returns a list of available animals. You could of course model it as a regular string
, but given that variants can be used as "typed strings", using a variant would give you much more benefit:
This is all fine and good as long as the API returns "Dog"
, "Cat"
or "Bird"
for animal
.
However, what if the API changes before you have a chance to deploy new code, and can now return "Turtle"
as well? Your code would break down because the variant animal
doesn't cover "Turtle"
.
So, we'll need to go back to string
, loosing all of the goodies of using a variant, and then do manual conversion into the animal
variant from string
, right?
Well, this used to be the case before, but not anymore! We can leverage untagged variants to bake in handling of unknown values into the variant itself.
Let's update our type definition first:
RESCRIPT@unboxed
type animal = Dog | Cat | Bird | UnknownAnimal(string)
Notice we've added @unboxed
and the constructor UnknownAnimal(string)
. Remember how untagged variants work? You remove the constructors and just leave the payloads. This means that the variant above at runtime translates to this (made up) JavaScript type:
type animal = "Dog" | "Cat" | "Bird" | string
So, any string not mapping directly to one of the payloadless constructors will now map to the general string
case.
As soon as we've added this, the compiler complains that we now need to handle this additional case in our pattern match as well. Let's fix that:
@unboxed
type animal = Dog | Cat | Bird | UnknownAnimal(string)
type apiResponse = {
animal: animal
}
let greetAnimal = (animal: animal) =>
switch animal {
| Dog => "Wof"
| Cat => "Meow"
| Bird => "Kashiiin"
| UnknownAnimal(otherAnimal) =>
`I don't know how to greet animal ${otherAnimal}`
}
There! Now the external API can change as much as it wants, we'll be forced to write all code that interfaces with animal
in a safe way that handles all possible cases. All of this baked into the variant definition itself, so no need for labor intensive manual conversion.
This is useful in any scenario when you use something enum-style that's external and might change. Additionally, it's also useful when something external has a large number of possible values that are known, but where you only care about a subset of them. With a catch-all case you don't need to bind to all of them just because they can happen, you can safely just bind to the ones you care about and let the catch-all case handle the rest.
Coercion
In certain situations, variants can be coerced to other variants, or to and from primitives. Coercion is always zero cost.
Coercing Variants to Other Variants
You can coerce a variant to another variant if they're identical in runtime representation, and additionally if the variant you're coercing can be represented as the variant you're coercing to.
Here's an example using variant type spreads:
RESCRIPTtype a = One | Two | Three
type b = | ...a | Four | Five
let one: a = One
let four: b = Four
// This works because type `b` can always represent type `a` since all of type `a`'s constructors are spread into type `b`
let oneAsTypeB = (one :> b)
Coercing Variants to Primitives
Variants that are guaranteed to always be represented by a single primitive at runtime can be coerced to that primitive.
It works with strings, the default runtime representation of payloadless constructors:
RESCRIPT// Constructors without payloads are represented as `string` by default
type a = One | Two | Three
let one: a = One
// All constructors are strings at runtime, so you can safely coerce it to a string
let oneAsString = (one :> string)
If you were to configure all of your construtors to be represented as int
or float
, you could coerce to those too:
RESCRIPTtype asInt = | @as(1) One | @as(2) Two | @as(3) Three
let oneInt: asInt = One
let toInt = (oneInt :> int)
Advanced: Coercing string
to Variant
In certain situtations it's possible to coerce a string
to a variant. This is an advanced technique that you're unlikely to need much, but when you do it's really useful.
You can coerce a string
to a variant when:
Your variant is
@unboxed
Your variant has a "catch-all"
string
case
Let's look at an example:
RESCRIPT@unboxed
type myEnum = One | Two | Other(string)
// Other("Other thing")
let asMyEnum = ("Other thing" :> myEnum)
// One
let asMyEnum = ("One" :> myEnum)
This works because the variant is unboxed and has a catch-all case. So, if you throw a string at this variant that's not representable by the payloadless constructors, like "One"
or "Two"
, it'll always end up in Other(string)
, since that case can represent any string
.
Tips & Tricks
Be careful not to confuse a constructor carrying 2 arguments with a constructor carrying a single tuple argument:
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:
Try not to do that, as this generates extra noisy output. Instead, use the @unboxed
attribute to guide ReScript to generate more efficient code:
Alternatively, define two external
s that both compile to the same JS call:
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:
JSlet 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:
The compiler sees the variant, then
conceptually turns them into
type animal = "Dog" | "Cat" | "Bird"
compiles
switch
to a constant-time jump table (O(1)
).