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.