Binding to a JavaScript Function that Returns a Variant in ReScript

ReScript provides easy ways to bind to most JavaScript functions in a way that feels both native and safe. Conveniently, it even provides an @unwrap decorator for parametric polymorphism. However, there are a few places where we still have to fill in the gaps. This article documents how to bind to a JavaScript function that can return any one of several different types using ReScript variants.

The need for a custom solution

JavaScript is both dynamic and weakly typed, and even the standard libraries take full advantage of those features in ways that can cause headaches for anyone trying to use a static type system.

TypeScript deals with this in a very literal way through union types. That is, the type is literally defined as OneType | TheOtherType so that the developer can account for both cases. ReScript does not have union types, but does have variants, which can be abstractions around different types.

Under the hood, these are JavaScript objects with properties that represent the underlying values.

sample output from the official documentation

var f1 = /* Child */0;
var f2 = {
  TAG: /* Mom */0,
  _0: 30,
  _1: "Jane"
};
var f3 = {
  TAG: /* Dad */1,
  _0: 32
};

It's sleek on the ReScript side, but nonnative to JS. This means there's no way under the current variant structure to directly bind to a method like IDBObjectStore.keypath, which could return null a string, or an array of strings. We can certainly represent a similar type like

IDBObjectStoreKeyPath.res

type t = Null | String(string) | Array(Js.Array.t<string>);

...but ReScript will expect that instances of this type will have TAG and numbered properties like the sample JavaScript output above. What we need is a way to classify what gets returned by our binding and call the appropriate variant constructor accordingly.

Writing a binding to a dummy type

We're going to end up doing a bit of unsafe black magic that we don't want our library users to use, so let's wrap it in a module to offset it from the code we'll expose in our .resi:

module Private = {
};

As we've established, there's no way to directly represent the returned value of keyPath in the ReScript type system, so let's not bother.

module Private = {
  type any;
  @get external keyPath: t => any = "keyPath";
};

Now, let's dig into the ugly stuff.

Thinking about types in JavaScript

Let's break out of ReScript for a moment and think about the JavaScript runtime side of things. If we were managing this in JavaScript, we would probably use the typeof operator to return a string, and then we could branch our logic accordingly.

But we can't only use typeof because typeof null and typeof [] both return "object", so we'll need a null check as well.

So if we were doing this in JavaScript, we'd end up with a piece of code something like

x => x === null ? "null" : typeof x

Let's hold on to that thought.

Modeling the type of the type in ReScript

Our JavaScript expression above will (for all IDBObjectStoreKeyPaths) return "null", "object", or "string". This translates very nicely to a ReScript polymorphic variant, like so:

type typeName = [ #null | #"object" | #"string" ];

So now, with this type, we can type our JavaScript expression in a %raw JavaScript snippet:

  type typeName = [ #null | #"object" | #"string" ];
  let getType: any => typeName = %raw(`x => x === null ? "null" : typeof x`);

So now we can get the keyPath through the binding, and we can then get the type name of that keyPath. We're so close.

magically calling the proper constructor

We have one last step: we need to switch on our typeName to call switch on our typeName, use Obj.magic to convert our type to the proper ReScript type, and then call our constructor, which will wrap our type in our variant.

  let classify = (v: any): IDBObjectStoreKeyPath.t => 
    switch(v -> getType) {
    | #null => IDBObjectStoreKeyPath.Null;
    | #"object" => IDBObjectStoreKeyPath.Array(v -> Obj.magic);
    | #"string" => IDBObjectStoreKeyPath.String(v -> Obj.magic);
    };

Obj.magic will cast the value to return whatever it infers, but our switch should ensure the cast is safe (in practice, though not in theory).

classifying any keyPath

Tying it all together, we can now use our classify function to sanitize the any dummy type returned from our keyPath binding.

let keyPath = (t: t): IDBObjectStoreKeyPath.t =>
  t -> Private.keyPath -> Private.classify;

(This is the kind of thing that gets me excited about functional programming-- when we break things into small enough pieces, anything seems easy and simple.)

Wrapping up

I hope this has been a useful resource for writing difficult bindings. Just to review, we were able to successfully return this variant...

IDBObjectStoreKeyPath.res

type t = Null | String(string) | Array(Js.Array.t<string>);

...from a function called keyPath by wrapping the binding like so:

IDBObjectStore.res

type t;

module Private = {
  type any;
  @get external keyPath: t => any = "keyPath";
  type typeName = [ #null | #"object" | #"string" ];
  let getType: any => typeName = %raw(`x => x === null ? "null" : typeof x`);
  let classify = (v: any): IDBObjectStoreKeyPath.t => 
    switch(v -> getType) {
    | #null => IDBObjectStoreKeyPath.Null;
    | #"object" => IDBObjectStoreKeyPath.Array(v -> Obj.magic);
    | #"string" => IDBObjectStoreKeyPath.String(v -> Obj.magic);
    };
};

/* properties */

let keyPath = (t: t): IDBObjectStoreKeyPath.t =>
  t -> Private.keyPath -> Private.classify;

I hope that this has been helpful for modeling union types using ReScript variants. For my part, I'm sure to refer back to this article as I continue writing and iterating on bindings.