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.
Website 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 in order 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 in order 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:
<!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>
Lets 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:
tsgetElementById(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 in order to call appendChild
.
Document.createElement
The definition for this method is (I have omitted the deprecated definition):
tscreateElement<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:
tsinterface 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 actually 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. Which 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 a 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:
tsappendChild<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.getElementByTagName("div")[0]; div.children; // HTMLCollection(2) [p, p] div.childNodes; // NodeList(2) [p, p]
After capturing the div
element, the children
prop will return a 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.getElementByTagName("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 getElementByTagName
, 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: