Write your own DOM element factory for TypeScript

The DOM api allows to manipulate the html document in a browser. It's simple to use, but the code is not very readable. Here's the code needed to create 2 elements and set an attribute:

function foo() {
    let div = document.createElement("div");
    let anchor = document.createElement("a");
    anchor.href = "https://www.meziantou.net";
    div.appendChild(anchor);
    return div;
}

The JSX syntax introduced by React allows to mix JavaScript and HTML-ish. The goal is the create a code that is easy to write and to read. JSX will be compiled by a preprocessor such as Babel (or TypeScript as we'll see later) to a valid JavaScript code. Using JSX, the previous function can be written as:

function foo() {
    return (
        <div>
            <a href="https://www.meziantou.net">meziantou</a>
        </div>
    );
}

If you use React, the code will be converted to:

function foo() {
    return React.createElement("div", { "class": className },
        React.createElement("a", { href: "https://www.meziantou.net" }, "meziantou"));
}

React.createElement creates a virtual DOM that will be translated to real DOM at the end. The idea of the virtual dom is to reduce the number of DOM operations, but it's not very important for this post… For simplicity, just imagine React.createElement is similar to document.createElement.

TypeScript supports the JSX syntax, so you can take advantages of the type checker, IDE autocompletion, and refactoring. TypeScript can preserve the JSX syntax (in case you want to use another precompiler), replace it with React.createElement, or using a custom factory. The last option allows you to use JSX without using React, as long as you provide your own factory.

A factory is just a function with the following declaration:

 interface AttributeCollection {
    [name: string]: string | boolean;
}

var Fragment;

function createElement(tagName: string, attributes: AttributeCollection | null, ...children: any[]): any

So, it's not very complicated to implement this function. You just need to use document.createElement, set the attributes, and add the children. However, there are some point of attentions.

  • JSX does not allow the attribute class. Instead, you have to use className. So, the factory has to handle this case.
  • Using JSX, you can register an event handler using <a onclick={...}></a>. However, the setAttribute function only accept a string value. So, you have to handle this case by using addEventListener.
  • Fragments (<>...</>) are replaced by factory.createElement(factory.Fragment, null, ...). So, we can use a special name to create a DocumentFragment in the createElement function.
namespace MyFactory {
    const Fragment = "<></>";

    export function createElement(tagName: string, attributes: JSX.AttributeCollection | null, ...children: any[]): Element | DocumentFragment {
        if (tagName === Fragment) {
            return document.createDocumentFragment();
        }

        const element = document.createElement(tagName);
        if (attributes) {
            for (const key of Object.keys(attributes)) {
                const attributeValue = attributes[key];

                if (key === "className") { // JSX does not allow class as a valid name
                    element.setAttribute("class", attributeValue);
                } else if (key.startsWith("on") && typeof attributes[key] === "function") {
                    element.addEventListener(key.substring(2), attributeValue);
                } else {
                    // <input disable />      { disable: true }
                    // <input type="text" />  { type: "text"}
                    if (typeof attributeValue === "boolean" && attributeValue) {
                        element.setAttribute(key, "");
                    } else {
                        element.setAttribute(key, attributeValue);
                    }
                }
            }
        }

        for (const child of children) {
            appendChild(element, child);
        }

        return element;
    }

    function appendChild(parent: Node, child: any) {
        if (typeof child === "undefined" || child === null) {
            return;
        }

        if (Array.isArray(child)) {
            for (const value of child) {
                appendChild(parent, value);
            }
        } else if (typeof child === "string") {
            parent.appendChild(document.createTextNode(child));
        } else if (child instanceof Node) {
            parent.appendChild(child);
        } else if (typeof child === "boolean") {
            // <>{condition && <a>Display when condition is true</a>}</>
            // if condition is false, the child is a boolean, but we don't want to display anything
        } else {
            parent.appendChild(document.createTextNode(String(child)));
        }
    }
}

Finally, you need to change the tsconfig.json file to indicate the TypeScript compiler how to convert JSX:

{
    "compilerOptions": {
        "jsx": "react", // use the React mode, so call the factory
        "jsxFactory": "MyFactory.createElement" // The name of the factory function
    }
}

You can now create a file with the extension .tsx, and use the JSX syntax. Hope this helps you writting DOM code!

If you want to see a real usage of a custom factory, you can check my Password Manager project on GitHub.