DOM Manipulation

DOM Manipulation

An exploration into the HTMLElement type

In the 20+ years since its standardization, JavaScript has come a very long way. While in 2020, JavaScript can be used on servers, in data science, and even on IoT devices, it is important to remember its most popular use case: web browsers.

Websites are made up of HTML and/or XML documents. These documents are static, they do not change. The Document Object Model (DOM) is a programming interface implemented by browsers to make static websites functional. The DOM API can be used to change the document structure, style, and content. The API is so powerful that countless frontend frameworks (jQuery, React, Angular, etc.) have been developed around it to make dynamic websites even easier to develop.

TypeScript is a typed superset of JavaScript, and it ships type definitions for the DOM API. These definitions are readily available in any default TypeScript project. Of the 20,000+ lines of definitions in lib.dom.d.ts, one stands out among the rest: HTMLElement. This type is the backbone for DOM manipulation with TypeScript.

You can explore the source code for the DOM type definitions

Basic Example

Given a simplified index.html file:

html
<!DOCTYPE html>
<html lang="en">
<head><title>TypeScript Dom Manipulation</title></head>
<body>
<div id="app"></div>
<!-- Assume index.js is the compiled output of index.ts -->
<script src="index.js"></script>
</body>
</html>

Let’s explore a TypeScript script that adds a <p>Hello, World!</p> element to the #app element.

ts
// 1. Select the div element using the id property
const app = document.getElementById("app");
// 2. Create a new <p></p> element programmatically
const p = document.createElement("p");
// 3. Add the text content
p.textContent = "Hello, World!";
// 4. Append the p element to the div element
app?.appendChild(p);

After compiling and running the index.html page, the resulting HTML will be:

html
<div id="app">
<p>Hello, World!</p>
</div>

The Document Interface

The first line of the TypeScript code uses a global variable document. Inspecting the variable shows it is defined by the Document interface from the lib.dom.d.ts file. The code snippet contains calls to two methods, getElementById and createElement.

Document.getElementById

The definition for this method is as follows:

ts
getElementById(elementId: string): HTMLElement | null;

Pass it an element id string and it will return either HTMLElement or null. This method introduces one of the most important types, HTMLElement. It serves as the base interface for every other element interface. For example, the p variable in the code example is of type HTMLParagraphElement. Also, take note that this method can return null. This is because the method can’t be certain pre-runtime if it will be able to actually find the specified element or not. In the last line of the code snippet, the new optional chaining operator is used to call appendChild.

Document.createElement

The definition for this method is (I have omitted the deprecated definition):

ts
createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
createElement(tagName: string, options?: ElementCreationOptions): HTMLElement;

This is an overloaded function definition. The second overload is simplest and works a lot like the getElementById method does. Pass it any string and it will return a standard HTMLElement. This definition is what enables developers to create unique HTML element tags.

For example document.createElement('xyz') returns a <xyz></xyz> element, clearly not an element that is specified by the HTML specification.

For those interested, you can interact with custom tag elements using the document.getElementsByTagName

For the first definition of createElement, it is using some advanced generic patterns. It is best understood broken down into chunks, starting with the generic expression: <K extends keyof HTMLElementTagNameMap>. This expression defines a generic parameter K that is constrained to the keys of the interface HTMLElementTagNameMap. The map interface contains every specified HTML tag name and its corresponding type interface. For example here are the first 5 mapped values:

ts
interface HTMLElementTagNameMap {
"a": HTMLAnchorElement;
"abbr": HTMLElement;
"address": HTMLElement;
"applet": HTMLAppletElement;
"area": HTMLAreaElement;
...
}

Some elements do not exhibit unique properties and so they just return HTMLElement, but other types do have unique properties and methods so they return their specific interface (which will extend from or implement HTMLElement).

Now, for the remainder of the createElement definition: (tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K]. The first argument tagName is defined as the generic parameter K. The TypeScript interpreter is smart enough to infer the generic parameter from this argument. This means that the developer does not have to specify the generic parameter when using the method; whatever value is passed to the tagName argument will be inferred as K and thus can be used throughout the remainder of the definition. This is exactly what happens; the return value HTMLElementTagNameMap[K] takes the tagName argument and uses it to return the corresponding type. This definition is how the p variable from the code snippet gets a type of HTMLParagraphElement. And if the code was document.createElement('a'), then it would be an element of type HTMLAnchorElement.

The Node interface

The document.getElementById function returns an HTMLElement. HTMLElement interface extends the Element interface which extends the Node interface. This prototypal extension allows for all HTMLElements to utilize a subset of standard methods. In the code snippet, we use a property defined on the Node interface to append the new p element to the website.

Node.appendChild

The last line of the code snippet is app?.appendChild(p). The previous, document.getElementById, section detailed that the optional chaining operator is used here because app can potentially be null at runtime. The appendChild method is defined by:

ts
appendChild<T extends Node>(newChild: T): T;

This method works similarly to the createElement method as the generic parameter T is inferred from the newChild argument. T is constrained to another base interface Node.

Difference between children and childNodes

Previously, this document details the HTMLElement interface extends from Element which extends from Node. In the DOM API there is a concept of children elements. For example in the following HTML, the p tags are children of the div element

tsx
<div>
<p>Hello, World</p>
<p>TypeScript!</p>
</div>;
const div = document.getElementsByTagName("div")[0];
div.children;
// HTMLCollection(2) [p, p]
div.childNodes;
// NodeList(2) [p, p]

After capturing the div element, the children prop will return an HTMLCollection list containing the HTMLParagraphElements. The childNodes property will return a similar NodeList list of nodes. Each p tag will still be of type HTMLParagraphElements, but the NodeList can contain additional HTML nodes that the HTMLCollection list cannot.

Modify the HTML by removing one of the p tags, but keep the text.

tsx
<div>
<p>Hello, World</p>
TypeScript!
</div>;
const div = document.getElementsByTagName("div")[0];
div.children;
// HTMLCollection(1) [p]
div.childNodes;
// NodeList(2) [p, text]

See how both lists change. children now only contains the <p>Hello, World</p> element, and the childNodes contains a text node rather than two p nodes. The text part of the NodeList is the literal Node containing the text TypeScript!. The children list does not contain this Node because it is not considered an HTMLElement.

The querySelector and querySelectorAll methods

Both of these methods are great tools for getting lists of dom elements that fit a more unique set of constraints. They are defined in lib.dom.d.ts as:

ts
/**
* Returns the first element that is a descendant of node that matches selectors.
*/
querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null;
querySelector<K extends keyof SVGElementTagNameMap>(selectors: K): SVGElementTagNameMap[K] | null;
querySelector<E extends Element = Element>(selectors: string): E | null;
/**
* Returns all element descendants of node that match selectors.
*/
querySelectorAll<K extends keyof HTMLElementTagNameMap>(selectors: K): NodeListOf<HTMLElementTagNameMap[K]>;
querySelectorAll<K extends keyof SVGElementTagNameMap>(selectors: K): NodeListOf<SVGElementTagNameMap[K]>;
querySelectorAll<E extends Element = Element>(selectors: string): NodeListOf<E>;

The querySelectorAll definition is similar to getElementsByTagName, except it returns a new type: NodeListOf. This return type is essentially a custom implementation of the standard JavaScript list element. Arguably, replacing NodeListOf<E> with E[] would result in a very similar user experience. NodeListOf only implements the following properties and methods: length, item(index), forEach((value, key, parent) => void), and numeric indexing. Additionally, this method returns a list of elements, not nodes, which is what NodeList was returning from the .childNodes method. While this may appear as a discrepancy, take note that interface Element extends from Node.

To see these methods in action modify the existing code to:

tsx
<ul>
<li>First :)</li>
<li>Second!</li>
<li>Third times a charm.</li>
</ul>;
const first = document.querySelector("li"); // returns the first li element
const all = document.querySelectorAll("li"); // returns the list of all li elements

Interested in learning more?

The best part about the lib.dom.d.ts type definitions is that they are reflective of the types annotated in the Mozilla Developer Network (MDN) documentation site. For example, the HTMLElement interface is documented by this HTMLElement page on MDN. These pages list all available properties, methods, and sometimes even examples. Another great aspect of the pages is that they provide links to the corresponding standard documents. Here is the link to the W3C Recommendation for HTMLElement.

Sources:

The TypeScript docs are an open source project. Help us improve these pages by sending a Pull Request

Contributors to this page:
EAEthan Arrowood  (6)
OTOrta Therox  (5)
SASafei Ashraf  (1)
MMateusz  (1)
IOIván Ovejero  (1)
6+

Last updated: Nov 20, 2024