Binding to a JavaScript Function that Returns a Variant in ReScript
webbureaucrat
Posted on June 11, 2021
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 IDBObjectStoreKeyPath
s) 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.
magic
ally 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).
classify
ing 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.
Posted on June 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.