Expose Polymorphic API to JavaScript

Continuing the discussion from Variant primitive type alias: I’m working on a library written in ReScript which will be consumed by TS and JS users. I wanted to open a new thread on this so as not to completely commandeer the prior discussion–

I’m interested in exposing a series of functions in my library to JavaScript where the invocation can be made with polymorphic types. That is, from JavaScript, I can call the same function with an array of elements that are either of type T or U, generically.

The original example I gave was this,

let f = (arr: array<@unwrap [ #Int(int) | #Float(float) ]>): float => {
    ...blah...
}

with the goal of invoking that function, whether in ReScript or in JS, with

f([1, 2.0, 4, 3.1])

This particular example is maybe too contrived because of how JavaScript and ReScript both respectively think about integers and floats, so I want to broaden this discussion to the more generic,

let f = (arr: array<@unwrap [ #First(T) | #Second(U) ]>): V => {
    ...blah...
}

Now we’re not dealing in ints or floats necessarily, and maybe non-primitive types at all. My understanding of ReScript is that to expose this type of API to a javascript user, that user might have to write f([{type: 'First', VAL: x}, {type: 'Second', VAL: y}]) in order for the implementation to ingest the arguments correctly. My goal is that the javascript user can simply write f([x, y]) and the rest be handled at runtime, even if that means using some type of runtime if (typeof x === 'number') { cast_to_number(x)->blah }.

Is this possible? How can I approach doing something like this? Thanks!

Hi @ncthom

Are you able to share more details about your use case? That may help with finding a solution.

Others might have some better options, but to me this could be a good case to write your function in JS and then create a binding to it in ReScript.

You mentioned JS and TS consumers. If you were to write this function in TS, how might the types look?

1 Like

There are a few different ways to handle this. In my experience, exposing a ReScript API to JS is always a bit clumsy and requires concessions in one way or another.

Since ReScript and JS have very different type systems, you need to think about exactly what kind of JS types you want your function to accept, and why. (What exactly are T and U going to be? Why would function f want to accept both of them?) In ReScript, you’re likely going to want to parse the JS input into a single type anyway, unless the rest of your app also needs to keep multiple types inside a variant.

If you need to keep different types organized into variants, then you can make life easier for JS consumers by exporting helper functions.

type t = String(string) | Int(int)
let string = x => String(x)
let int = x => Int(x)

In JS:

f([string("foo"), int(1)]);

Another solution is to treat all JS input as JSON data, and then parse it with a function like Js.Json.classify. One advantage to this is that it lets you handle errors in case the JS caller sends completely the wrong type.

If you go with this approach, then it may also make sense to write the API you expose to JS as a wrapper around the internal ReScript functions. Example:

// ReScript API
let add_res = (a, b) => a +. b

// Parse function
let parse_number = x =>
  switch Js.Json.classify(x) {
  | JSONNumber(x) => x
  | JSONString(x) =>
    switch Belt.Float.fromString(x) {
    | Some(x) => x
    | None => Js.Exn.raiseError(`${x} cannot be parsed as a number.`)
    }
  | _ => Js.Exn.raiseError(`${Js.typeof(x)} cannot be parsed as a number.`)
  }

// JS API
let add_js = (a, b) => add_res(parse_number(a), parse_number(b))
2 Likes

Thanks @kevanstannard @johnj your replies make a lot of sense. I understand the difficulty here, but I’m hopeful there is a way to present a nice API from ReScript here.

The specifics of my case are that I have a record representing a “Node” and I want to provide a library of functions which can construct these Node records. Nodes can have children, and those children are expressed when calling these library functions. The API that I want is one where a “Node” can be expressed either using the explicit record type or using a primitive float number:

  type rec node = {
    kind: kind,
    props: props,
    children: array<node>,
  }

Creating a node just trivially constructs records of that type. But now I want to expose a these library functions which operate over node-type records or numbers:

let mul = (a: node | number, b: node | number): node => {
  // Here, create a node-type record whose children consist of `a` and `b`
  // such that if either `a` or `b` is of type number, I'll first convert it to type `node`
  // by constructing a representative node record
}

Ultimately, the TypeScript API that I want to provide to my end users looks a lot like that last function: a function which takes node | number arguments such that the caller can
trivially and implicitly write mul(5, mul(5, mul(5, 1))). (Note the implicit mixing of argument types).

Is this doable? Thanks for your help!

1 Like

One solution is to define a function to explicitly turn an int into a node. Example:

type node = // implementation goes here
let mul: (node, node) => node = // implementation goes here
let mul_int: int => node = // implementation goes here

This method’s advantage is that it works easily with the ReScript type system without any hacks or tricks. Its disadvantage is that the JS consumer has to be a bit more verbose:

mul(mul_int(5), mul(mul_int(5), mul(mul_int(5), mul_int(1))))

We can make it a less painful for the JS by adding a wrapper around the pure ReScript code.

external unsafe_cast_int: 'a => int = "%identity"
external unsafe_cast_node: 'a => node = "%identity"
let classify = x =>
  if Js.typeof(x) == "number" {
    mul_int(unsafe_cast_int(x))
  } else {
    unsafe_cast_node(x)
  }
let mul_js = (a, b) => mul(classify(a), classify(b))
1 Like

Thanks @johnj that makes sense! I guess the only downside that I see in that example is that mul_js now takes arguments of arbitrary type, which is, of course, how Javascript works, but I’m also hoping to @genType my library so that the output of my compiled rescript provides typescript definitions which ideally would enumerate these mul_js style functions as taking not arbitrary type arguments, but strictly node | number.

It’s almost that the API I’m aiming for is using Variants but with implicit construction of the correct variant at invocation-time. Is that doable?

type t =
  | Node(Node.t)
  | Number(float)

let mul = (a: t, b: t): Node.t => {
  ...blah
}

// Implicitly constructs the variant type t at call time so
// that the first instance of `mul` here receives `Number, Node` and the second
// receives `Number, Number`
mul(1, mul(1, 2))
}
1 Like

The only way to implicitly construct a value based on its type at runtime is by using a function like my classify example. And if you’re doing that, then it probably makes sense to just directly turn the float into a Node.t at runtime and avoid using a variant altogether. (Unless you need the variant for other reasons.)

As far as the Typescript definitions go, maybe someone else can offer better suggestions for that. I’m not aware if GenType can directly do what you want here, but I’m not an expert on it.

1 Like

Ok yea that makes good sense @johnj thank you!

I think what I’ll do is use a rather strict ReScript API that only deals in Node.t types, address the node | number business in some typescript wrapper functions, and convert number->node before calling into the ReScript API. That way my genType stuff is sound and the flexibility I want is just a thin wrapper on top of the exported genType defs

3 Likes