A good `createElement`?

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 :blush:

1 Like

It could probably done with an approach similar to Proposal: type-safe event listeners

Someone would actually have to sit down and write the type-safe helpers though. Not a fun piece of work.

1 Like

I lost your proposal, which seems quite interesting. I need to experiment a bit with it in order to have a better idea, but nevertheless (unfortunately) it does not solve a problem I did not mention in my post.

Take the case in which the output does not depend on just a single string, but on an array of possible strings. For instance (in TS):

interface Output {
  a: string;
  b: number;
  c: string[];
  d: number[];
  e: Record<string, string|number>;
}

export function test<T extends keyof Output>(tags: T[]): Pick<Output, T>;

This relatively simple FFI interface to test function (which, unfortunately, has a structure pretty common in JS) is a huge PITA to write in Rescript using single functions as interfaces, because the number of APIs is the factorial of the number of fields (!!!) nope, it’s not factorial, but for my example, there are 24 combinations. Obviously it would also be a bad experience for the user, because each different set of used keys maps to a different function name.

Now, I don’t want you to get me wrong, I would really like that Rescript would be much more used (because this would create traction and it would make the community bigger), but because I know that these situations could cause troubles, it’s hard for me to push it as a strong alternative to TS in developer teams. Even if I personally hate to fight against the TS compiler.
My point is, as always, to highlight some sharp edges that could make adoption harder, in order to smooth them and have something better :blush:

I’m not quite understanding what this example does. Can you show the implementation?

Sorry, my fault, I should have explained the example. Instead of creating a dummy implementation, I think it can be more useful to just explain what the declaration express.

The basic idea is that the test function accepts an array of strings, but these strings can only be a subset of the keys for Output. For instance, you can call test(["a", "c", "e"]) but not test(["hello"]). What the function returns is a subset of the Output interface (a record type in Rescript) with only the keys specified in the tags argument.

To make the code a bit more clear, let’s expand Pick (which is a standard utility helper type):

type Pick<T, Keys> = { [K in keyof T]: K extends Keys ? T[K] : never };

Which means, given two generic T and Keys, Pick<T, Keys> is a record containing a set of “indexable keys” K taken from the keys of T, where the value type is the same as in T if K “is available in” Keys, otherwise the bottom type never. This last thing means that if K does not exist in Keys, K: T[K] won’t exist in the record.

Just tell me if I have been clear enough, I tried to make it short and concise.

OK but what’s the motivation? The example seems like code gymnastics. What is the real-world problem that we are trying to solve?

1 Like

Not at all, this approach is extremely useful in a huge amount of situations. I have code in production with code conceptually like this (but more complex in terms of types) in order to correctly model functions relative to REST APIs and the world around it. In any case, even if you struggle to believe that this is something useful, it is pretty hard to think that Microsoft is having fun with language features just for “code gymnastics”, right?

It’s not the ReScript way, explicitly create another type instead.

I didn’t say that Microsoft is doing code gymnastics, only that this specific example seems to be. If you can provide a specific problem you are trying to solve, perhaps we can provide a more idiomatic way to do it in ReScript.

Each REST API that supports dynamic dimensions and metrics has this exact problem. For instance, try to implement Google reporting APIs with correctly typed rows field in the response (with the possibility of getting one metric/dimension only if specified in the request without using options, just to make it clear), you will find out that it’s not trivial at all. In this specific case it is totally unfeasable to use a single function+type for each combination of dimensions+metrics, because there are a ton of possible combinations.

1 Like

If I understood the problem right, I’d migrate the data to a variant when it enters my system.

2 Likes