Skip to content

Commit 1bf628f

Browse files
authored
Variant docs (#740)
* untagged variants * variant type spread docs * variant coercion * catch-all case * rearrange
1 parent 959daba commit 1bf628f

File tree

1 file changed

+198
-34
lines changed

1 file changed

+198
-34
lines changed

pages/docs/manual/latest/variant.mdx

+198-34
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,26 @@ var me = {
152152

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

155+
## Variant Type Spreads
156+
Just like [with records](record#record-type-spread), it's possible to use type spreads to create new variants from other variants:
157+
158+
```rescript
159+
type a = One | Two | Three
160+
type b = | ...a | Four | Five
161+
```
162+
163+
Type `b` is now:
164+
```rescript
165+
type b = One | Two | Three | Four | Five
166+
```
167+
168+
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:
169+
- 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.
170+
- All variants and constructors must share the same runtime configuration - `@unboxed`, `@tag`, `@as` and so on.
171+
- You can't spread types in recursive definitions.
172+
173+
Note that you need a leading `|` if you want to use a spread in the first position of a variant definition.
174+
155175
### Pattern Matching On Variant
156176

157177
See the [Pattern Matching/Destructuring](pattern-matching-destructuring) section later.
@@ -160,10 +180,9 @@ See the [Pattern Matching/Destructuring](pattern-matching-destructuring) section
160180

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

163-
- If the variant value is a constructor with no payload, it compiles to a string.
164-
- 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.
165-
- 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.
166-
- 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.
183+
- If the variant value is a constructor with no payload, it compiles to a string of the constructor name. Example: `Yes` compiles to `"Yes"`.
184+
- 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 of `TAG` is the constructor name as string by default, but note that the name of the `TAG` field as well as the string value used for each constructor name [can be customized](#tagged-variants).
185+
- 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 the `TAG` field as per the previous rule.
167186

168187
Check the output in these examples:
169188

@@ -294,7 +313,7 @@ Now, this maps 100% to the TypeScript code, including letting us bring over the
294313

295314
### String literals
296315

297-
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.
316+
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:
298317

299318
```typescript
300319
// direction.ts
@@ -303,9 +322,18 @@ type direction = "UP" | "DOWN" | "LEFT" | "RIGHT";
303322

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

325+
### Valid `@as` payloads
326+
Here's a list of everything you can put in the `@as` tag of a variant constructor:
327+
- A string literal: `@as("success")`
328+
- An int: `@as(5)`
329+
- A float: `@as(1.5)`
330+
- True/false: `@as(true)` and `@as(false)`
331+
- Null: `@as(null)`
332+
- Undefined: `@as(undefined)`
333+
306334
## Untagged variants
307335

308-
With _untagged variants_ it is possible to represent a heterogenous array.
336+
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:
309337

310338
```rescript
311339
@unboxed type listItemValue = String(string) | Boolean(bool) | Number(float)
@@ -323,10 +351,38 @@ var myArray = ["hello", true, false, 13.37];
323351

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

326-
### Pattern matching on nullable values
354+
### Advanced: Unboxing rules
355+
#### No overlap in constructors
356+
A variant can be unboxed if no constructors have overlap in their runtime representation.
357+
358+
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.
359+
The same goes for two records - even if they have fully different shapes, they're still JavaScript `object` at runtime.
360+
361+
Don't worry - the compiler will guide you and ensure there's no overlap.
362+
363+
#### What you can unbox
364+
Here's a list of all possible things you can unbox:
365+
- `string`: `String(string)`
366+
- `float`: `Number(float)`. Notice `int` cannot be unboxed, because JavaScript only has `number` (not actually `int` and `float` like in ReScript) so we can't disambiguate between `float` and `int` at runtime.
367+
- `bool`: `Boolean(bool)`
368+
- `array<'value>`: `List(array<string>)`
369+
- `promise<'value>`: `Promise(promise<string>)`
370+
- `Dict.t`: `Object(Dict.t<string>)`
371+
- `Date.t`: `Date(Date.t)`. A JavaScript date.
372+
- `Blob.t`: `Blob(Blob.t)`. A JavaScript blob.
373+
- `File.t`: `File(File.t)`. A JavaScript file.
374+
- `RegExp.t`: `RegExp(RegExp.t)`. A JavaScript regexp instance.
375+
376+
Again notice that the constructor names can be anything, what matters is what's in the payload.
377+
378+
> **Under the hood**: Untagged variants uses a combination of JavaScript `typeof` and `instanceof` 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.
379+
380+
### Pattern matching on unboxed variants
381+
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.
382+
383+
Here's an example of pattern matching on an unboxed nullable value that illustrates the above:
327384

328385
```rescript
329-
// 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
330386
module Null = {
331387
@unboxed type t<'a> = Present('a) | @as(null) Null
332388
}
@@ -345,12 +401,13 @@ let getBestFriendsAge = user =>
345401
| _ => None
346402
}
347403
```
404+
No difference to how you'd do with a regular variant. But, the runtime representation is different to a regular variant.
348405

349406
> 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.
350407
351408
### Decoding and encoding JSON idiomatically
352409

353-
With untagged variants, we have everything we need to define a JSON type:
410+
With untagged variants, we have everything we need to define a native JSON type:
354411

355412
```rescript
356413
@unboxed
@@ -370,9 +427,8 @@ Here's an example of how you could write your own JSON decoders easily using the
370427
```rescript
371428
@unboxed
372429
type rec json =
373-
| @as(false) False
374-
| @as(true) True
375430
| @as(null) Null
431+
| Boolean(bool)
376432
| String(string)
377433
| Number(float)
378434
| Object(Js.Dict.t<json>)
@@ -432,43 +488,151 @@ let usersToJson = users => Array(users->Array.map(userToJson))
432488

433489
This can be extrapolated to many more cases.
434490

435-
// ### Unboxable types
491+
### Advanced: Catch-all Constructors
492+
With untagged variants comes a rather interesting capability - catch-all cases are now possible to encode directly into a variant.
493+
494+
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:
436495

437-
// 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.
496+
<CodeTab labels={["ReScript", "JS Output"]}>
497+
```rescript
498+
type animal = Dog | Cat | Bird
438499
439-
// ### Catch all
500+
type apiResponse = {
501+
animal: animal
502+
}
440503
441-
// TODO #733: Add a small section on the "catch all" trick, and what kind of things that enable.
504+
let greetAnimal = (animal: animal) =>
505+
switch animal {
506+
| Dog => "Wof"
507+
| Cat => "Meow"
508+
| Bird => "Kashiiin"
509+
}
510+
```
511+
```javascript
512+
```
513+
</CodeTab>
442514

443-
// ## Variant spread
444515

445-
// TODO #732
516+
This is all fine and good as long as the API returns `"Dog"`, `"Cat"` or `"Bird"` for `animal`.
517+
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"`.
446518

447-
## Coercion
519+
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?
520+
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.
448521

449-
You can convert a variant to a `string` or `int` at no cost:
522+
Let's update our type definition first:
523+
```rescript
524+
@unboxed
525+
type animal = Dog | Cat | Bird | UnknownAnimal(string)
526+
```
527+
528+
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:
529+
```
530+
type animal = "Dog" | "Cat" | "Bird" | string
531+
```
532+
So, any string not mapping directly to one of the payloadless constructors will now map to the general `string` case.
533+
534+
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:
450535

451536
<CodeTab labels={["ReScript", "JS Output"]}>
537+
```rescript
538+
@unboxed
539+
type animal = Dog | Cat | Bird | UnknownAnimal(string)
452540
453-
```res
454-
type company = Apple | Facebook | Other(string)
455-
let theCompany: company = Apple
541+
type apiResponse = {
542+
animal: animal
543+
}
456544
457-
let message = "Hello " ++ (theCompany :> string)
545+
let greetAnimal = (animal: animal) =>
546+
switch animal {
547+
| Dog => "Wof"
548+
| Cat => "Meow"
549+
| Bird => "Kashiiin"
550+
| UnknownAnimal(otherAnimal) =>
551+
`I don't know how to greet animal ${otherAnimal}`
552+
}
553+
```
554+
```javascript
555+
function greetAnimal(animal) {
556+
if (!(animal === "Cat" || animal === "Dog" || animal === "Bird")) {
557+
return "I don't know how to greet animal " + animal;
558+
}
559+
switch (animal) {
560+
case "Dog" :
561+
return "Wof";
562+
case "Cat" :
563+
return "Meow";
564+
case "Bird" :
565+
return "Kashiiin";
566+
567+
}
568+
}
458569
```
570+
</CodeTab>
459571

460-
```js
461-
var theCompany = "Apple";
462-
var message = "Hello " + theCompany;
572+
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.
573+
574+
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.
575+
576+
## Coercion
577+
In certain situations, variants can be coerced to other variants, or to and from primitives. Coercion is always zero cost.
578+
579+
### Coercing Variants to Other Variants
580+
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.
581+
582+
Here's an example using [variant type spreads](#variant-type-spreads):
583+
```rescript
584+
type a = One | Two | Three
585+
type b = | ...a | Four | Five
586+
587+
let one: a = One
588+
let four: b = Four
589+
590+
// This works because type `b` can always represent type `a` since all of type `a`'s constructors are spread into type `b`
591+
let oneAsTypeB = (one :> b)
463592
```
464593

465-
</CodeTab>
594+
### Coercing Variants to Primitives
595+
Variants that are guaranteed to always be represented by a single primitive at runtime can be coerced to that primitive.
596+
597+
It works with strings, the default runtime representation of payloadless constructors:
598+
```rescript
599+
// Constructors without payloads are represented as `string` by default
600+
type a = One | Two | Three
601+
602+
let one: a = One
603+
604+
// All constructors are strings at runtime, so you can safely coerce it to a string
605+
let oneAsString = (one :> string)
606+
```
607+
608+
If you were to configure all of your construtors to be represented as `int` or `float`, you could coerce to those too:
609+
```rescript
610+
type asInt = | @as(1) One | @as(2) Two | @as(3) Three
611+
612+
let oneInt: asInt = One
613+
let toInt = (oneInt :> int)
614+
```
615+
616+
### Advanced: Coercing `string` to Variant
617+
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.
618+
619+
You can coerce a `string` to a variant when:
620+
- Your variant is `@unboxed`
621+
- Your variant has a "catch-all" `string` case
622+
623+
Let's look at an example:
624+
```rescript
625+
@unboxed
626+
type myEnum = One | Two | Other(string)
627+
628+
// Other("Other thing")
629+
let asMyEnum = ("Other thing" :> myEnum)
630+
631+
// One
632+
let asMyEnum = ("One" :> myEnum)
633+
```
466634

467-
// TODO #731: expand this section with:
468-
//
469-
// Coercing between variants (and the constraints around that)
470-
// Why you can sometimes coerce from variant to string/int/float, and how to think about that (runtime representation must match)
471-
// The last additions of allowing coercing strings to unboxed variants with catch-all string cases
635+
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`.
472636

473637
## Tips & Tricks
474638

@@ -620,12 +784,12 @@ switch data {
620784
```js
621785
console.log("Wof");
622786

623-
var data = /* Dog */0;
787+
var data = "Dog";
624788
```
625789

626790
</CodeTab>
627791

628792
The compiler sees the variant, then
629793

630-
1. conceptually turns them into `type animal = 0 | 1 | 2`
794+
1. conceptually turns them into `type animal = "Dog" | "Cat" | "Bird"`
631795
2. compiles `switch` to a constant-time jump table (`O(1)`).

0 commit comments

Comments
 (0)