Binding to a JavaScript Function that Returns a Variant in ReScript
javascript indexeddb rescript variantNote from the future: In the current year, none of
this article is relevant anymore. Use Rescript's
@unboxed
attribute instead. It solves the whole problem I was
trying to solve with this article, but much more
cleanly. I will leave this article here as a historical
record.
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.
I write to learn, so I welcome your constructive criticism. Report issues on GitLab.