DEV Community

loading...

Emulating TypeScript union types with ReasonML

Yawar Amin
Programming languages enthusiast. Author of Learn Type Driven Development: https://www.packtpub.com/application-development/learn-type-driven-development
・2 min read

YESTERDAY Gary Bernhardt posted a compelling write-up about the benefits they got from converting a JavaScript application to TypeScript. One of the really cool examples in that post is how TypeScript can automatically generate a union type for you by examining a JavaScript object literal during typechecking. That got me to thinking about how ReasonML/OCaml might be able to do the same thing.

The TypeScript version

Here's a simplified version of the TypeScript example:

const icons = {
  arrowDown: {label: 'Down Arrow'},
  arrowLeft: {label: 'Left Arrow'},
}

type IconName = keyof typeof icons

function iconLabel(iconName: IconName): string {
  return icons[iconName].label
}

console.log(iconLabel('arrowLeft'))
// "Left Arrow"

/* console.log(iconLabel('bob'))

   ERROR: Argument of type '"bob"' is not assignable to parameter of type
   '"arrowDown" | "arrowLeft"'. */

The key (no pun intended) line is this one: type IconName = keyof typeof icons. TypeScript is examining the icons object during typechecking, making a list of all its keys, and turning that into a union of the keys as strings:

arrowDown, arrowLeft => 'arrowDown' | 'arrowLeft'

TypeScript is able to do this because it enforces that all the object keys must be known during typechecking. It doesn't allow you to, for example, assign a new key like icons.bob = 'Bob Arrow', because once it sees the object literal's keys, it decides those are the only ones that are allowed.

This is great for all the reasons that Bernhardt goes into detail in his post, but long story short it's a convenient way to automatically tie together the icon data to the icon label getter function and make sure they never go out of sync with each other.

When I saw this I wondered if a straight translation to Reason would be possible, because Reason doesn't have TypeScript's level of support for union types. It does, however, have polymorphic variant types which have much the same level of power.

The Reason version

After some trial and error, I came up with this:

let icons =
  fun
  | `arrowDown => {"label": "Down Arrow"}
  | `arrowLeft => {"label": "Left Arrow"};

let iconLabel = name => icons(name)##label;

let () = Js.log(iconLabel(`arrowLeft));
// "Left Arrow"

/* let () = Js.log(iconLabel(`bob));

   ERROR:

   This has type:
     [> `bob ]
   But somewhere wanted:
     [< `arrowDown | `arrowLeft ]
   The second variant type does not allow tag(s) `bob */

The biggest difference in the Reason version is that icons is not an object, but a function. This is because of the way polymorphic variant inference works. Only by using a function can we guarantee that the function will accept only valid values of the variant. In this way we emulate a union type.

A slightly smaller but still convenient difference: we don't need to annotate the iconLabel function type. Reason automatically infers from usage that its parameter must be the polymorphic variant type that the icons function takes as well, and that its return type must be a string because that's the type of an icon object's label prop. This is all completely type-safe for the same reasons the TypeScript version is, and offers the same maintenance and refactoring benefits.

Discussion (8)

Collapse
citizen428 profile image
Michael Kohl • Edited

This intrigued me, so I dug a bit deeper. OCaml has extensible variant types:

type t = ..;;
type t += UpArrow;;
type t += DownArrow;;

You can then pattern match on this almost like on every other variant, but for the match to be exhaustive you have to add a default case.

Now while this doesn't get us too far by itself, one could potentially write a PPX rewriter like this:

type t = ..

let icons =
  function
  | match%union: DownArrow -> {"label": "Down Arrow"}
  | match%union: UpArrow -> {"label": "Up Arrow"}

which rewrites to something like this:

type t+= DownArrow
type t+= UpArrow

let icons = 
  function
  | DownArrow -> {"label": "Down Arrow"}
  | UpArrow -> {"label": "Up Arrow"};

I'm not sure it's worth the effort over just using the polymorphic variant, though at least you'd be safe from typos etc. (polymorphic variants won't warn you about things like `DonwArrow). Might be a fun quarantine project.

Collapse
yawaramin profile image
Yawar Amin Author

Extensible variants are fun indeed 🙂 the technique I suggested with polyvariants will warn about typos though because of the way the function is defined with the polyvariant value being the function parameter. Its type is inferred as [< `arrowDown | `arrowLeft], so a typo like `arrowDonw will give the same kind of type error as I show in the post.

Collapse
citizen428 profile image
Michael Kohl • Edited

polyvariants will warn about typos though

If you misspell it in the match expression inside icons, polymorphic variants can't know that that's not what you meant, so you won't find the typo until exercising the code, worst case at runtime.

This is opposed to the following code, that won't compile with the error The variant constructor ArrowDonw can't be found.

type t =
  | ArrowDown
  | ArrowLeft;

let icons =
  fun
  | ArrowDonw => {"label": "Down Arrow"}
  | ArrowLeft => {"label": "Left Arrow"};

A similar example is given in Real World OCaml, which points out that this can be particularly problematic when combining polymophic variants with catch-all cases. I.e. if you had a final clause like

| _ => {"label": "Unknown button"}

it could take quite a while until you find the typo in the match.

There are certainly times when polymorphic variants are the only reasonable choice (your example is a good one), but I think one needs to be aware of potential pitfalls.

Thread Thread
yawaramin profile image
Yawar Amin Author

Try out a typo in the icons definition in my code sample, you'll see the error at compile time 🙂 In fact you even hinted as to why: because I didn't use a catch-all case. That's exactly what causes the polymorphic variant type to be inferred as 'less than these cases', as I mentioned before. Definitely agree though that these are subtleties that need to be understood for proper usage. Real World OCaml is a good resource to learn about those, for sure.

Thread Thread
citizen428 profile image
Michael Kohl • Edited

Try out a typo in the icons definition in my code sample, you'll see the error at compile time 🙂

But that's because you're exercising icons via iconLabel inside the JS.log function. If you remove that it will compile just fine:

Playground

Generally, you wouldn't have a JS.log statement in a library, so if you happen to call icons("arrowDown"); during runtime (i.e. by taking it from a keypress event) you'd get an error about it not being part of [<arrowDonw | arrowLeft ] then, not at compile time.

This wouldn't happen with a regular union type:

Playground 2

That's really all I tried to say 😀

Thread Thread
yawaramin profile image
Yawar Amin Author

Hmm, I still don't see how that could happen though 🤔 at some point in the code we would call icons(something) where something is a polymorphic variant tag. At that point the typechecker would step in to point out if you called it with icons(`arrowDown) which is not handled by the icons function (assuming the typo in its definition). You wouldn't be calling the icons function with a string, it's not stringly typed.

Thread Thread
citizen428 profile image
Michael Kohl

Yup, sorry, the string was a mistake, should have been `arrowDown.

I guess I was thinking too much about a hypothetical case that is either extremely unlikely or impossible, so thanks for taking the time and patiently explaining 😀

Collapse
citizen428 profile image
Michael Kohl

This is something Elixir really nailed with external resources:

oestri.ch/2018/03/elixir-external-...

In short you can link a module to a file, define a macro that will turn the resource's content into code and be done with it. The module gets recompiled if the resource changes.