I was wondering: are we able to express a safe, ergonomic and zero-cost API for document.createElement
?
Let me introduce the problem: it would be nice if, given a type with polymorphic variants representing a HTML tags, I could call document->createElement(tag)
and get back the right HTML type, without costs in the compiled JS.
Let’s discuss possible approach. The naive one is just having the function that returns a generic Dom.htmlElement
(I consider document
to be an htmlDocument
, I don’t want to open the can of worms related to using a document
type or an xmlDocument
). This unfortunately returns the correct type only sometimes, because calling with an #img
variant does not get me back an htmlImageElement
. And obviously it would be necessary to use fallible conversions or unsafe approaches, totally not the best situation.
Another approach is to create an unsafeCreateElement
function that returns a generic 'a
type, then write all the create*Element
functions that internally use unsafeCreateElement
. Ok, I can live with the boilerplate, but the real problem is that now I have a function in the compiled JS for each HTML tag. And even with dead code optimization, all the functions used to create an HTML element will stay, creating an unfortunate cost on the bundle size (keep in mind that we are just talking about one function, things would get worse in decent sized projects).
Fine, third approach! We can use GADT in order to create something like the following:
type htmlElementTagName = [#a | #abbr | #area] /* and all the other HTML tags! */
type rec htmlElementTagNameMap<'a> =
| A([#a]): htmlElementTagNameMap<[#a] => Dom.htmlAnchorElement>
| Abbr([#abbr]): htmlElementTagNameMap<[#abbr] => Dom.htmlElement>
| Area([#area]): htmlElementTagNameMap<[#area] => Dom.htmlAreaElement>
@send
external createElementUnsafe: (t<'a>, [< htmlElementTagName]) => 'b = "createElement"
let createElement:
type b c. (t<'a>, htmlElementTagNameMap<b => c>) => c =
(doc, tag) => {
switch tag {
| A(tag) => createElementUnsafe(doc, tag)
| Abbr(tag) => createElementUnsafe(doc, tag)
| Area(tag) => createElementUnsafe(doc, tag)
}
}
Looks promising, isn’t it? Ok, it is boilerplate, but if we can get safety and good JS output we should be fine, right? Let’s see how to use it:
let a = document->createElement(A(#a))
Mmmmhhh… meh. I need to pass the poly variant to the relative variant, which is not so great from an usability standpoint. And before you ask: if I remove the tag from the GADT, then I need to explicitly pass the correct value to createElementUnsafe
, and the compiler cannot elide the if
statement anymore.
And at this point, I honestly don’t have any other idea. Did I miss a possible approach? I would like your opinion about that, I am sure many of you could see some possible ways I could not think of because of my inexperience.
But – because my question is more related to the expressiveness than really writing createElement
– if the trinity of ergonomics, safety/right-type and zero-cost cannot be reached today, how the language could be improved?
I generally have a strange love and hate relationship with Typescript, because many, many times, I fall in situations in which a sound type system would have avoided me a lot of pain. Even if TS is a huge improvement over pure JS, I often need to write horrible cumbersome meta-programming logic based on type extension in order to express the type I need. But, I have to admit, they solve the problem I was talking about in an elegant way with type literals and a sort of compile-time reflection (in case you are interested, here’s the declaration of createElement
). I am totally not suggesting that this would be a good solution for Rescript, there is a good probability that a similar approach would not be feasible. I am just saying that we can try to take inspiration from the parts that work well.
Let me know what you thing, and if you have an actual solution for the problem I showed. I hope I made a point around something Rescript could be improved