Bindings: how DOM objects exist in JavaScript
Learn how Web IDL turns browser objects into JavaScript wrappers with conversions, prototypes, live collections, DOMException, and legacy quirks.
- 01Read a bindingExplain how Web IDL lets your JavaScript use a browser feature, and how to read a short Web IDL line.
- 02Predict conversionsSay what a
DOMString, aUSVString, and asequenceinput do to the value you pass in. - 03Debug browser objectsSpot the same-object rule, prototypes, live and static lists,
DOMException, anddocument.all.
A browser API is a binding
Your code can call document.createElement, read document.body, and build a URL. These are not part of the JavaScript language itself. They come from the browser.
The browser is a big program. Most of it is written in other languages, such as C++ or Rust. Its own objects, like the page and its elements, live inside that program. We call them platform objects.
So how does your JavaScript reach them? Through a binding. A binding is the set of rules that makes a browser feature show up in JavaScript. It decides:
- which objects, methods, and properties your code can see,
- how the values you pass in are converted,
- which object you get back when you read something twice,
- which error is thrown when something goes wrong,
- a few odd old rules kept so old websites still work.
Web IDL is a small language that web standards use to write these rules down. It lists what a browser feature offers and how each part looks in JavaScript. Behind those rules, the browser can be written in C++, Rust, or any other language.
This lesson follows How input reaches your event handlers. Your event handlers are JavaScript functions. But the event objects, the targets, and the document methods they use all reach you through bindings. After this lesson, Realms, globals & WindowProxy explains why each page and each iframe gets its own set of these objects.
Web IDL is the printed manual
Think of a TV remote. You press a button, and the electronics inside the TV do the work. The printed manual says what each button accepts and what it does.
You can change the channel without ever seeing the circuits. You only need the buttons. The manual tells you what each button does, so you know what will happen before you press it.
- In real life: Pressing a remote button
- In JavaScript: Calling a method in JavaScript
- In real life: The electronics inside the TV
- In JavaScript: The browser's own code and objects
- In real life: The printed manual
- In JavaScript: The Web IDL description of a feature
- In real life: Each button takes one kind of input
- In JavaScript: The binding converts and checks what you pass in
A Web IDL description is called an interface. It lists two kinds of parts:
- attributes, which become properties you can read, like
document.body, - operations, which become methods you can call, like
setAttribute.
Web IDL also says how each value you pass in is converted before the method runs. If the conversion fails, the method does not run at all. This explains many browser surprises.
// Simplified Web IDL. The real DOM standard has much more.
interface Element {
undefined setAttribute(DOMString name, DOMString value);
};
const box = document.createElement("div");
box.setAttribute("data-count", 5);
console.log(box.getAttribute("data-count"));Lines 2 to 4 are a short, simplified piece of Web IDL. They are not copied from the real DOM standard. Read them like this:
- Line 2,
interface Element, names the kind of object: an element on the page. - Line 3 describes the
setAttributemethod. The first word,undefined, is what it gives back: nothing useful. Inside the brackets are its two inputs, anameand avalue. - Both inputs have the type
DOMString. That means "turn this into a JavaScript string first".
Lines 6 to 8 are normal JavaScript. Line 7 passes the number 5 as the value. The binding turns it into the string "5" before the browser stores it. Line 8 reads the attribute back and prints 5, which is now text.
5. The binding follows the DOMString rule and turns it into the text "5" before the browser stores it.To read any Web IDL line, go in this order: the kind of object, the method name, what it gives back, then the type of each input. Web IDL tells you what your JavaScript will see. It does not say how the browser is built inside.
How values are converted: DOMString, USVString, sequences
A browser method does not take just any value as it is. Each input has a Web IDL type, and the binding converts your value to that type first. This section covers three common types.
First, a word about how JavaScript stores text. A string is a row of small pieces called code units. Most characters use one piece. Some characters, like many emoji, need a pair of two pieces. One half of a pair left on its own is called a lone surrogate. It is not a complete character.
- A DOMString is a normal JavaScript string. It can hold anything a string can, even a lone surrogate.
- A USVString is stricter. Every character must be complete, so each lone surrogate is swapped for the replacement character, U+FFFD (it looks like �).
const box = document.createElement("div");box.setAttribute("data-count", 5);console.log(typeof box.getAttribute("data-count"));Line 1 creates a div element. It is not on the page yet. Line 2 gives setAttribute the number 5. The binding turns it into the string "5". So line 3 prints string, not number.
A small model of what setAttribute does with the number 5: each line is one step of the conversion.
script
const text = String(value);const attribute = text;console.log(typeof attribute);const url = new URL("https://shop.example/?q=\uD800");console.log(url.search);Line 1 builds a URL. The text ends with \uD800, which is a lone surrogate. The URL input is a USVString, so the binding swaps the lone surrogate for U+FFFD. Line 2 prints ?q=%EF%BF%BD. That is how a URL writes U+FFFD: as three bytes, each written as % and two letters or digits.
Why the difference? A DOMString keeps your string exactly as it is. A USVString makes a DOMString first, then swaps each lone surrogate for U+FFFD. URLs need complete characters, so they use the stricter type.
This does not mean you should clean every string in your app. It is a rule for one input of one method. When you are unsure, ask: what Web IDL type does this input have? The answer tells you how your value will be converted.
The third type is a sequence. A sequence is a list. The binding builds it from any iterable: a value you can loop over with for...of, such as an Array or a Set.
const snacks = new Set(["tea", "cake"]);const blob = new Blob(snacks);console.log(blob.size);Line 1 makes a Set with two strings. A Set is iterable. Line 2 passes it to Blob, which holds a chunk of data. Its input is a sequence, so the binding loops over the Set and builds a list of parts. Line 3 prints 7, the size in bytes: tea is 3 bytes and cake is 4.
const letters = new Set(["a", "b"]);
const copied = [...letters];
console.log(copied.join(","));This example shows the same idea in plain JavaScript. Line 1 makes a Set that gives "a", then "b", when you loop over it. Line 2 uses ... (spread) to copy those values into a new Array. Line 3 prints a,b. A sequence works the same way: it makes a new list from the values. It does not keep the Set itself.
The table below compares the three types. Read each row from left to right: the type, what it means, and one example from this lesson.
| Type | What it means | Example from this lesson |
|---|---|---|
DOMString | A normal JavaScript string. It can hold anything a string can, even a lone surrogate. | setAttribute turns the number 5 into the string "5". |
USVString | A stricter string. Every character must be complete, so each lone surrogate becomes U+FFFD. | new URL swaps a lone surrogate for U+FFFD. |
sequence<T> | A new list, copied from any value you can loop over with for...of. T is the type of each item. | new Blob accepts a Set of parts. |
One DOM node, one JavaScript object
Inside the browser, each part of the page, like the body, is a browser object. Your code never touches that object directly. Instead, it gets a JavaScript object that stands for it. People often call this JavaScript object a wrapper.
const firstBody = document.body;const secondBody = document.body;console.log(firstBody === secondBody);Line 1 reads document.body into firstBody. Line 2 reads it again into secondBody. Line 3 prints true. Both reads give the very same JavaScript object. That is why comparing elements with === and adding event listeners work the way you expect.
body object, so firstBody === secondBody is true. The browser does not make a new object each time you read document.body.You don't need to know what language the browser is written in. Web IDL lets each browser build things its own way. What it does promise is what your JavaScript sees: the same node always gives the same object, with the same methods.
So keep this simple picture in mind: your code holds a JavaScript object, and the browser does the real work behind it. That is also why you can't turn a DOM node into text, send it somewhere else, and get the same node back.
Web IDL also has a marker called [SameObject]. Don't mix it up with the rule above. It is a promise about one property: reading that property twice on the same object gives the same result. It is not written on every DOM property, and it does not make different nodes share one object.
const body = document.body;
body.lessonNote = "tea";
console.log(document.body.lessonNote);
console.log(body === document.body);
delete body.lessonNote;Line 1 reads the body once into body. Line 2 adds a normal JavaScript property, lessonNote, to that object. Line 4 reads document.body again and prints tea, so the new read sees the property. Line 5 prints true: both names still point to the same object.
Line 7 deletes the property again, so the example leaves the page as it found it. The example shows two separate facts:
lessonNoteis a normal JavaScript property that lives on the wrapper.- The wrapper itself always stands for the same browser object.
The browser's inner object is not a second object you can reach from JavaScript. You only ever work with the wrapper.
Add a note to the body, read the body again, and see that both reads give the same object. This replay shows what your JavaScript sees, not how the browser is built inside.
script
body.lessonNote = "tea";const again = document.body;console.log(again.lessonNote);console.log(body === again);Interface objects and prototypes
Each Web IDL interface gets a global name in JavaScript, such as HTMLElement. The value behind that name is called the interface object. It is a function, so typeof HTMLElement gives "function". (typeof tells you what kind of value something is.)
console.log(typeof HTMLElement);try { new HTMLElement();} catch (error) { console.log(error.name);}Line 1 prints function. Line 3 tries to make a new element with new HTMLElement(). The browser refuses, because HTML elements must be made by the document. The error is caught by catch, and line 5 prints its name, TypeError. So a function name for an interface does not always mean you can use new with it.
Every object also has a prototype: another object it can borrow methods from. That object has its own prototype, and so on. This line of objects is called the prototype chain. Object.getPrototypeOf(x) gives you the next object up from x.
let current = document.body;for (let step = 0; step < 5; step += 1) { current = Object.getPrototypeOf(current); console.log(current.constructor.name);}Line 1 starts at the body object. The loop runs five times. Each time, line 3 moves one step up the chain. Line 4 prints the name of the interface that step belongs to. The five names are HTMLBodyElement, HTMLElement, Element, Node, and EventTarget.
document.body show the five names line 4 prints, in order.Step up the prototype chain from document.body, one prototype at a time. This replay copies what a real browser prints.
script
let current = document.body; for (let step = 0; step < 5; step += 1) { current = Object.getPrototypeOf(current); names.push(current.constructor.name);} console.log(names.join(" -> "));This chain is where shared methods come from. The body can call addEventListener because that method lives on the EventTarget prototype, higher up the chain. Each body object does not keep its own copy of every method.
The interface object and its prototype do different jobs:
HTMLElementis the interface object, the global name.HTMLElement.prototypeis the shared object in each element's prototype chain. The methods live here, so the browser does not need to put a copy of each one on every element.
A DOM method also checks which object it is called on. That object is called the receiver. Inside the method, it is this. You can pick the receiver yourself with .call(): method.call(obj, arg) runs method with obj as its receiver.
const button = document.createElement("button");const append = Node.prototype.appendChild;try { append.call({}, button);} catch (error) { console.log(error.name);}console.log(append.call(document.body, button) === button);Line 1 creates a button. Line 2 saves the appendChild method in append. Line 4 calls it with a plain empty object, {}, as the receiver. That object is not a real node, so the binding refuses and line 6 prints TypeError. Line 8 calls the same method on document.body, a real node. It returns the button, so the line prints true. Some browsers write "Illegal invocation" in the error message. Check the error's name, not its message.
The check asks: is this object really a node made by the browser? Having the right prototype is not enough. A different rule, the [LegacyUnforgeable] marker, is used by a few old properties. It puts them right on each object, where they can't be removed or replaced, instead of on the shared prototype.
Back to the new HTMLElement() error. Every interface object is a function. But only interfaces that list a constructor in Web IDL can make objects with new. Elements are made by the document instead, with document.createElement. That way the document can set up each element correctly.
Live and static lists of elements
Some old browser objects act partly like normal objects and partly like lists. You can read their items by number, like list[0], or sometimes by name. Web IDL calls these legacy platform objects. "Legacy" means they are kept so old websites still work. New browser features are not designed this way.
Two methods find elements and give you a list back:
getElementsByTagNamegives a live list, called anHTMLCollection. It keeps changing to match the page.querySelectorAllgives a static list, called aNodeList. It keeps the matches it found when you called it.
const list = document.createElement("ul");list.innerHTML = "<li>tea</li>";const live = list.getElementsByTagName("li");const snapshot = list.querySelectorAll("li");list.append(document.createElement("li"));console.log(live.length, snapshot.length);Lines 1 and 2 make a ul list with one list item: an li that says tea. Line 3 gets a live list of its li items. Line 4 gets a static list of the same items. Line 5 adds a second, empty item. Line 6 prints 2 1. The live list now sees two items. The static list still has the one it found on line 4.
live holds both items. snapshot still holds only the item that was there when line 4 ran. So line 6 prints 2 1.const list = document.createElement("ul");
list.innerHTML = "<li>tea</li>";
const live = list.getElementsByTagName("li");
const snapshot = list.querySelectorAll("li");
list.append(document.createElement("li"));
console.log(live.length, snapshot.length);Items in the list: 1
Live list (HTMLCollection): 1
Static list (NodeList): 1
The live list sees 1 item. The static list still has 1 item: the one it found when the code ran.
The table compares the three kinds of list objects. Read each row as: the object, what happens after the page changes, and an example.
| Object | What happens after the page changes? | Example |
|---|---|---|
A live list, HTMLCollection | It changes to match the page. The next time you read it, you see the new items. | getElementsByTagName gives you one. |
A static list, NodeList | It does not change. It keeps the items it found when you called the method. | querySelectorAll gives you one. |
| A legacy platform object | That depends on the object. What they share: you can read items by number, like list[0]. | Both lists above are legacy platform objects. They act like Arrays, but they are not Arrays. |
"Live" does not mean it redraws anything on the screen. It only means that when you read the list later, you may get different items, because the page changed. If you need a list that stays the same, copy it into an Array with Array.from(live).
A live list answers collection[0] even though it is not an Array. That is the old legacy behavior at work. Reading items by name is another old rule. These are features of old browser lists. They are not a pattern to copy in your own code.
In the playground, you change one thing: how many items the list has. Reset goes back to one item. The live count follows the page. The static count stays at the number found when the query ran. When a list seems to change by itself, ask: is it live or static?
DOMException: the browser's error object
When a browser feature fails, it often throws a DOMException. This is an error object from the browser. It has a name, such as "AbortError", and a message. It is not one of JavaScript's own error types, like TypeError. But it works like an error, and you can check for it with instanceof DOMException. (x instanceof Y asks: was x made by Y?)
const stopped = new DOMException("Stopped", "AbortError");console.log(stopped.name, stopped instanceof DOMException);Line 1 makes a DOMException. The first input is the message, "Stopped". The second is the name, "AbortError". Line 2 prints AbortError true. Your code should use the name to tell what went wrong. Don't use the old number codes.
try { document.createElement("1abc");} catch (error) { console.log(error.name);}Line 2 asks the document for an element named 1abc. Element names can't start with a digit, so the browser throws. Line 4 prints InvalidCharacterError. That is the DOMException name for "this text has a character that isn't allowed here".
A DOMException tells you that you broke one of a feature's rules. But not every browser error is a DOMException. Some failures throw a normal JavaScript TypeError instead, like the receiver check earlier.
const problem = new DOMException("Bad name", "InvalidCharacterError");
console.log(problem.name);
console.log(problem.code);
console.log(problem instanceof DOMException);Line 1 makes a DOMException with a message and a name. Line 2 prints the name, InvalidCharacterError. Line 3 prints 5, an old number code for the same error. Line 4 prints true. In your own code, check the name. The number codes are only kept for old code.
The 1abc example got its DOMException from the browser. This example makes one directly. Either way, check a fixed name like InvalidCharacterError. Don't check the message: it can be different in each browser.
document.all, the odd one out
document.all is left over from very old browsers. It gives back a list of every element on the page, called an HTMLAllCollection. You can read its properties, like document.all.length. But the HTML standard gives it some strange rules, so that very old websites keep working.
console.log(typeof document.all);console.log(Boolean(document.all));console.log(typeof document.all.length);Line 1 prints undefined. Line 2 prints false, so document.all counts as false in an if. Line 3 prints number, because the list still has a real length. So it is not truly the value undefined.
Why? Old websites checked if (document.all) to find out which browser they were in. To keep those sites working, the standard gives this one object a special hidden flag, written [[IsHTMLDDA]]. The flag changes three things:
typeofsays"undefined".- It counts as false in an
ifor inBoolean(). - Loose equality,
==, treats it likeundefinedandnull.
Don't use document.all to check for features in new code. It is only there so old pages keep working.
For example, document.all == null is true. But document.all === undefined is false, because strict equality, ===, is not fooled. You can't copy this trick with your own object, a Proxy, or a custom valueOf method.
The double square brackets are how the standard writes a hidden flag. JavaScript code can't add [[IsHTMLDDA]] to an object. The HTML standard gives it only to HTMLAllCollection.
When a browser API surprises you
Bindings are most useful to know when a browser feature does something you didn't expect. First, look up the method and the type of each input. Each of these surprises follows from the binding rules:
- a number that comes back as text from an attribute,
- a URL where a character was swapped for U+FFFD,
- a list that changes length by itself.
Then pick the check that fits the question:
- Is it the same object? Compare with
===. - Where does a method come from? Walk the chain with
Object.getPrototypeOf. - Did a list change? Check if it is a live
HTMLCollectionor a staticNodeList.
setAttributeturns5into"5"new URLswaps a lone surrogate for U+FFFDdocument.body === document.bodyistrueHTMLElement.prototypeholds shared methods- A list gets longer after you add an element to the page
- An object that counts as false, with
typeofgiving"undefined"
Put each card in the group that explains it best.
For everyday practice with these objects, see the lessons on the DOM tree, node properties, attributes and properties, and the prototype chain.
Common misconceptions
- “The DOM is plain JavaScript.” DOM objects are browser objects. Your code reaches them through a binding.
- “Every interface name works with new.” An interface object is a function, but
new HTMLElement()still throws aTypeError. - “All DOM lists are Arrays.” They are special list objects. Some are live and some are static.
- “document.all is undefined.” It acts like
undefinedin a few checks, but it is still a list with a length. - “DOMException is just Error.” It is the browser's own error type, with names like
AbortError.
Knowing these differences tells you where to look when something goes wrong:
- A value changed type? Look at how the input was converted.
- A method is missing or odd? Look at the prototype chain.
- A list changed? Check whether it is live.
Practice exercises
What does the last line print?
const box = document.createElement("div");
box.setAttribute("data-count", 5);
console.log(typeof box.getAttribute("data-count"));The answer is string. The binding turns the number 5 into the string "5" before the browser stores it.
You pass new URL some text with a lone surrogate in it. The input is a USVString. What character takes the lone surrogate's place?
The answer is U+FFFD, the replacement character. A USVString swaps each lone surrogate for it.
Your shopping list page adds new items after you look them up. You want a list that sees the new items too. Which method should you use?
const live = list.getElementsByTagName("li");getElementsByTagName gives a live list, so it sees items added later. querySelectorAll keeps only the items it found when it ran.
What is the name of the DOMException made on line 1?
const stopped = new DOMException("Stopped", "AbortError");
console.log(stopped.name, stopped instanceof DOMException);The name is AbortError. It is the second input to new DOMException, and it is kept as it is.
document.all counts as false in an if, but it still has a length. What hidden flag causes this?
The answer is the hidden [[IsHTMLDDA]] flag. It makes document.all count as false and makes typeof say undefined. The object is still a real list, so it still has a length.
In your app, a form value comes back different after you pass it to a browser method. What should you look at before you write a fix?
Look at the Web IDL for that method in its standard, and check the type of each input. Then try the smallest call in the console to see the conversion.
Check your understanding
For each question, think about the binding rule first. Then predict the result.
Question 1 of 8What is Web IDL used for?
Choose an answer to see the explanation.
Question 2 of 8What does this print?
Read the code, then predictconsole.log(new URL("https://shop.example/?q=\uD800").search);Choose an answer to see the explanation.
Question 3 of 8Why does
new HTMLElement()throw?Choose an answer to see the explanation.
Question 4 of 8A live
HTMLCollectionholds one item. You add a matching element to the page. What happens to the list?Choose an answer to see the explanation.
Question 5 of 8What does this code print?
Read the code, then predictconst error = new DOMException("Stopped", "AbortError"); console.log(error.name, error instanceof DOMException);Choose an answer to see the explanation.
Question 6 of 8Why is
document.allunusual?Choose an answer to see the explanation.
Question 7 of 8A browser method's input is a Web IDL sequence. What can you pass in?
Choose an answer to see the explanation.
Question 8 of 8Your code catches a DOMException. What should it check to tell what went wrong?
Choose an answer to see the explanation.
Key takeaways
- Web IDL writes down how a browser feature shows up in JavaScript: its objects, methods, and properties.
- The binding converts what you pass in. A DOMString is a normal string. A USVString swaps lone surrogates for U+FFFD. A sequence copies the values out of any iterable.
- The same DOM node always gives the same JavaScript object. Its shared methods come from prototypes up the chain.
- A live list changes when the page changes. A static list keeps what it found.
- A DOMException has a name, like
AbortError. Check the name, not the message. document.allis an old special case, kept on purpose for old websites.
Remember the one-liner.
A binding is the set of rules that lets your JavaScript use the browser's own objects.
Coming next: Realms, globals & WindowProxy.