Greetings everyone.
Being inspired by this beautiful blog post, I was trying to express bindings to web APIs without using include (the approach of rescript-webapi). Something like the following:
module Document = {
type t<'a> = Dom.document_like<'a>
@send
external querySelectorAll: (t<'a>, string) => Dom.nodeList = "querySelectorAll"
@get
external body: t<'a> => Dom.element = "body"
}
module Node = {
type t<'a> = Dom.node_like<'a>
@send
external appendChild: (t<'a>, Dom.node_like<'b>) => Dom.node_like<'b> = "appendChild"
}
module Element = {
type t<'a> = Dom.element_like<'a>
let asNullHtmlElement: Dom.element => Js.null<Dom.htmlElement> = %raw(`
function (x) {
if (x instanceof HTMLElement) {
return x;
} else {
return null;
}
}
`)
let asHtmlElement: Dom.element => option<Dom.htmlElement> = element => {
element->asNullHtmlElement->Js.Null.toOption
}
}
module HTMLElement = {
type t<'a> = Dom.htmlElement_like<'a>
@get
external innerText: t<'a> => string = "innerText"
}
This would let you write something like this:
let appendElementToBody: Element.t<'a> => unit = element => {
document->Document.body->Node.appendChild(element)->ignore
element
->Element.asHtmlElement
->Belt.Option.forEach(htmlElement => {
htmlElement->HTMLElement.innerText->Js.log
})
}
This approach has a great advantage over using different modules that include all related functions, because if you suddenly change an HTMLElement to a HTMLBodyElement, everything still works fine and you don’t need to change all the occurrences of HTMLElement.function to HTMLBodyElement.function.
Now, the problem.
I would like to express what in Rust is called associated type. I am not an Haskeller, but if I did get it right, a similar approach can be expressed with family types.
The reason I need this (which is a limitation expressed in the blog I mentioned above), is to return a different output type depending on the input type. Let me show you a concrete example.
@val
external document: Dom.htmlDocument = "document"
module Document = {
type t<'a> = Dom.document_like<'a>
@send
external createElement: (t<'a>, string) => ??? = "createElement"
}
As you can see, document is an htmlDocument, but createElement should be available on Document module. createElement should return a Dom.element if t<'a> = Dom.document, a Dom.htmlElement if t<'a> = Dom.htmlDocument.
I tried to use GADT to express this, but (probably because I really don’t know anything about GADT
) I am not able to express the constraint between the document and the element types. Take the following code, for instance:
type rec createElementOut<'a> =
| Document(Dom.element): createElementOut<Dom.document>
| HtmlDocument(Dom.htmlElement): createElementOut<Dom.htmlDocument>
@send
external createElement: (t<'a>, string) => createElementOut<t<'a>> = "createElement"
At this point I am not able to correctly write the eval function for createElementOut:
let eval: createElementOut<'a> => 'b = (type a b, doc: createElementOut<a>): b => {
switch doc {
| Document(x) => x
| HtmlDocument(x) => x
}
}
With this, you get the error that I am returning Dom.element instead of b. If I omit the local scoped type, I just get the error that Dom.htmlElement is returned where Dom.element is expected (obviously).
I also tried to use two generic types for createElementOut, something like this:
type rec createElementOut<'a, 'b> =
| Document(Dom.element): createElementOut<Dom.document, Dom.element>
| HtmlDocument(Dom.htmlElement): createElementOut<Dom.htmlDocument, Dom.htmlElement>
let eval: createElementOut<'a, 'b> => 'b = (type a b, doc: createElementOut<a, b>): b => {
switch doc {
| Document(x) => x
| HtmlDocument(x) => x
}
}
But then you get an unconstrained type, which means that the implementation is unsound:
let x = document->createElement("div")->eval
// ^--- This is `'a` instead of `Dom.htmlElement`
Do you know if there is a way to express this idea? I totally understand that trying to bend OCAML (and therefore Rescript) functional approaches to express concepts extremely tied to OOP is not the best, but on the other hand we need to make these two worlds interact, and it would be very nice if we could do that without sacrificing ergonomics.

.
