A dark title card with line.isLine() struck through and isLine(line) below it

I Added 16 Methods to Every DOM Element. AI Changed the Trade-off.

2026.08.10

For years, every DOM Element in my app had 16 methods the browser had never defined.

Type a dot after any Element variable, and editor-specific helper methods appeared in autocomplete. I could write code that read like a sentence without adding a single import. When I was developing the app alone, those methods were a real productivity feature. Extending Element.prototype was what made them possible.1주 1 The shared object every Element on the page inherits from. Built-in browser methods such as hasAttribute and closest come from there too.

Over the past year, as AI agents began writing a substantial share of the code with me, the trade-off changed. I recently removed 15 of the 16 methods. The migration touched 315 call sites across 34 files.

The code had not suddenly become bad. What changed were the assumptions around it.

How the editor renders a document

I build an Electron-based Markdown note-taking app. Although it is a desktop application, its interface and editor are rendered with HTML and JavaScript, much like a web page.

The browser represents the screen as a tree called the DOM, where each HTML tag corresponds to an Element. My editor renders each line of a document as a single Element. In simplified form, it looks like this:

<!-- Structure and attribute names simplified for this article -->
<div class="editorArea">
  <div data-line>Just a line of text</div>
  <div data-line data-line-type="table_cell">A line representing a table cell</div>
  <div data-line>
    <span data-md-type="bold_open">**</span>
    <span data-md-type="bold_in">Bold text</span>
    <span data-md-type="bold_close">**</span>
  </div>
</div>

The editor records the state of lines and Markdown elements in attributes such as data-line, data-line-type, and data-md-type. As a result, the same checks appear throughout the code: Is this Element a line? What type of line is it? Is this span a bold marker?

How I used Element.prototype

I wanted to turn those checks into functions that I could call from any file without an import and discover in autocomplete whenever I typed a dot after an Element variable. Extending Element.prototype gave me both.

The helper functions looked like this:

// md-constants.ts — excerpt from the actual code before removal
// When called as el.isLine(), this refers to that Element
export function isLine(this: Element) {
  return this.hasAttribute(Attribute.LINE)
}

export function getLineType(this: Element) {
  return this.getAttribute(Attribute.LINE_TYPE)
}

export function hasMdType(this: Element, mdType: string) {
  // Find mdType among the comma-separated values in data-md-type
}

// ... 16 helper functions in total

Every Element inherits from Element.prototype, which also supplies built-in browser methods such as el.hasAttribute(...) and el.closest(...). Add a function there, and every existing or future Element can call it. That is what the editor did during startup.

// Global type declarations and installation code — excerpt from the actual code before removal
import * as helper from './md-constants'

declare global {
  interface Element {
    isLine(): boolean
    getLineType(): string | null
    hasMdType(mdType: string): boolean
    setLineType(lineType: string): void
    showMdElement(): void
    hideMdElement(): void
    // ... 16 method declarations in total
  }
}

Element.prototype.isLine = helper.isLine
Element.prototype.getLineType = helper.getLineType
Element.prototype.hasMdType = helper.hasMdType
// ... 16 assignments in total

In the actual implementation, this file was imported along the application’s startup path so that the assignments ran during initialization. The declare global block told TypeScript to treat these methods as if they were part of the built-in Element type. That enabled type checking and autocomplete.

Once the file had run, I could write this anywhere in the app:

// Anywhere in the app, without a single import
if (line.isLine() && line.getLineType() === 'table_cell') { ... }

if (anchor.hasMdType('bold_open')) { ... }

Why it worked so well

This design had clear advantages when I was developing alone.

First, autocomplete became a dictionary for the editor’s domain. This was the biggest benefit at the time. Methods such as isLine, getLineType, and hasMdType appeared alongside the browser’s built-in methods. I did not have to remember their names or where they were defined. The tooling remembered the editor’s vocabulary for me.

Second, the imports disappeared. These helpers formed the editor’s basic vocabulary and were used almost everywhere. With the prototype approach, I could create a new file and call line.isLine() immediately, without any setup.

Third, the code read like a sentence. The subject–verb order of line.isLine() felt more natural than isLine(line) and blended smoothly with the browser’s own API.

line.hasAttribute('data-line')  // A method supplied by the browser
line.isLine()                   // A method I added

All of these benefits rested on one shared assumption:

I held the context of the code in my head.

The rule that said, “These methods are for use inside the editor,” was not written anywhere in the code. When I was the only person using them, my memory enforced it.

What became a problem

Once I began developing with AI agents and relying more heavily on tests, the same design started to look different. Autocomplete mainly helps a person typing code and scanning suggestions. Now that AI writes much of the code with me, the largest benefit had mostly disappeared from my workflow, but the costs remained.

1. AI could cross a boundary that existed only in my head

The prototype extension also created another route for code outside the editor to call functions inside it.

Two routes for code outside the editor to call a function inside it. The import route is recorded in the module graph; the prototype call leaves no record.

The first route leaves an import behind. A developer or a static-analysis tool can look at that line and see that the file depends on the editor. The second route does not record that dependency in the caller’s imports or in the module graph. Because startup code had installed the methods on every Element in the app, any file could call editor logic without declaring the module dependency.

An AI agent cannot consistently know a rule that exists only in my head unless I put it into the current working context. The global type declaration also presented these helpers as ordinary methods on every Element. A pattern permitted by the types and surrounding code is a pattern an AI agent can naturally repeat. Files outside the editor were already calling editor functions through this route.

The cost becomes visible when you try to establish a real boundary. To encapsulate the editor, I first needed to know what outside code was using. The import graph could not tell me. I had to search the entire codebase separately for those method calls.

2. Unit tests bypassed the extension installation

These methods existed only after the startup code had run. That always happened in the app, but unit tests that exercised individual functions without launching the application did not go through that initialization. Elements in the test environment had no extension methods such as isLine.

That mismatch left traces in the application code.

// Three variations of the same concern: “Call the method if it exists”
element.isLine && element.isLine()
typeof line.isLine === 'function' && line.isLine()
lastLine?.isLine?.()  // Silently skip if the value or method does not exist

There were 18 defensive guards like these. In the app, the method checks were always true because the extension methods were always installed. The methods were absent mainly in isolated unit tests that did not run the application’s startup path. That meant the calls behind those guards had never executed in those tests. The tests were green, but those paths had never been verified.

One test bypassed the real predicate entirely by attaching the expected result directly to a fake line.

// services/__tests__/memo-draft-materializer.test.ts — before removal
const fakeLine = { isLine: () => true, ... }

The real isLine checks for a data-line attribute. That logic did not execute at all in this test. The test had invented its own definition of what counted as a line. The actual predicate could change without the test noticing.

I could have imported the prototype installation file in the test setup. But then the unit tests would depend on a global initialization order, while the hidden module dependency would remain. I did not want a workaround that merely made the tests pass. I wanted to make the dependency visible.

Removing the extensions

Fortunately, the functions had been standalone functions from the beginning. The prototype assignments were only a thin convenience layer on top of them. Removing that layer was closer to a mechanical migration than a redesign.

// before
export function isLine(this: Element) {
  return this.hasAttribute(Attribute.LINE)
}
line.isLine()

// after — this simply became the first parameter
export function isLine(el: Element) {
  return el.hasAttribute(Attribute.LINE)
}
import { isLine } from '$md/md-constants'
isLine(line)

The challenge was scale. Changing 315 call sites by hand would inevitably introduce mistakes. I wrote a one-off script that parsed the source code into a syntax tree2주 2 Reading code as grammatical structure rather than as text. Unlike string replacement, it will not touch the same name inside a comment or a string. and replaced more than 300 calls in bulk. I then reviewed by hand the 18 sites where optional values or defensive guards required semantic judgment.

Removing the global declarations made the compiler flag any prototype-style calls the script had missed. I used those errors, along with a final codebase search, to verify the migration.

I also added guardrails against reintroducing the pattern. A lint rule now rejects assignments to Element.prototype. I removed the global type declarations as well, so writing el.isLine() now produces a compile-time error.

None of this makes boundary violations impossible. Code outside the editor can still write import { isLine } from '$md/md-constants' and call the function. What changed is that doing so must leave an explicit import behind. The dependency did not disappear; it could no longer remain hidden. Once I can see it, I can define a rule for it and count the places that violate that rule.

The trade-off changed

Extending Element.prototype was not a mistake. Autocomplete, import-free calls, and sentence-like code had real value, and I benefited from them for years.

Most of that value was immediate: easier discovery, fewer imports, smoother call sites. The cost appeared when the context had to move out of my head. The module graph could not reveal every boundary crossing, and isolated tests did not share the application’s runtime initialization.

When I was developing alone, I was both the person writing the code and the person holding its context. Now that AI handles a substantial share of the typing, the convenience matters less while the need to express boundaries in the code matters more. The code stayed the same. The trade-off changed.

This is not just about Element.prototype

Global injection is only one example of a design that hides dependencies. Implicit global state, monkey patching3주 3 Modifying another module’s objects at runtime., and hidden instance registries share the same structure: the dependency does not appear at the code’s boundary, and in return, the code becomes shorter and smoother.

That trade can make sense when someone remembers the hidden dependencies and a small team shares the context of the codebase. But AI agents naturally repeat patterns permitted by the types and surrounding code. A rule enforced through human memory must be reintroduced into every working context, while a dependency absent from the module boundary is harder for code review and ordinary dependency tooling to catch.

So when I evaluate a convenience now, I ask where its cost goes. Does it make the code easier to write by moving a dependency out of sight? If so, who is responsible for remembering it? If the answer is “a person’s memory,” the design is more expensive than it used to be. Whenever possible, dependencies should appear in imports, signatures, and types. That is what makes it possible to define rules and count violations.

The difference between line.isLine() and isLine(line) is only a few characters. But the former hides the source of the dependency at the call site, while the latter requires an import that tooling can see. When I was coding alone, that difference was mostly a matter of taste. Now that I build with AI, it has become an architectural decision.