Recipe · Node and platforms

How do I type a custom element (Web Component) in JavaScript?

  • Browser (no bundler)
  • Web Components
  • Custom elements
  • HTMLElementTagNameMap
  • Checked with TypeScript 5.9

The problem I wrote a price-tag custom element in plain JavaScript. Inside the class everything is typed, but document.querySelector('price-tag') gives me Element and my cents property is unknown. How do I connect the tag name to the class?

The short answer

Write the element as a class that extends HTMLElement, typed with JSDoc like any class. Then add its tag name to HTMLElementTagNameMap in a .d.ts file:

price-tag.js price-tag.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
export class PriceTag extends HTMLElement {
  static observedAttributes = ["cents", "currency"];

  get cents() {
    return Number(this.getAttribute("cents") ?? 0);
  }

  /** @param {number} value */
  set cents(value) {
    this.setAttribute("cents", String(value));
  }

  connectedCallback() {
    this.render();
  }

  /**
   * @param {string} name
   * @param {string | null} oldValue
   * @param {string | null} newValue
   */
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) this.render();
  }

  render() {
    const currency = this.getAttribute("currency") ?? "USD";
    this.textContent = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(this.cents / 100);
  }
}

customElements.define("price-tag", PriceTag);
export class PriceTag extends HTMLElement {
  static observedAttributes = ["cents", "currency"];

  get cents(): number {
    return Number(this.getAttribute("cents") ?? 0);
  }

  set cents(value: number) {
    this.setAttribute("cents", String(value));
  }

  connectedCallback(): void {
    this.render();
  }

  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
    if (oldValue !== newValue) this.render();
  }

  render(): void {
    const currency = this.getAttribute("currency") ?? "USD";
    this.textContent = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(this.cents / 100);
  }
}

customElements.define("price-tag", PriceTag);
A custom element class, registered and added to the tag name map.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js", "*.d.ts"]
}

The declaration file imports the class as a type and maps "price-tag" to it:

elements.d.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
import type { PriceTag } from "./price-tag.js";

declare global {
  interface HTMLElementTagNameMap {
    "price-tag": PriceTag;
  }
}
A custom element class, registered and added to the tag name map.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js", "*.d.ts"]
}

With both in place, document.querySelector("price-tag") returns PriceTag | null and document.createElement("price-tag") returns a PriceTag, so cents is checked everywhere:

page.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
const total = document.querySelector("price-tag");
if (total) total.cents = 4999;

const lamp = document.createElement("price-tag");
lamp.cents = 3900;
document.body.append(lamp);
A custom element class, registered and added to the tag name map.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js", "*.d.ts"]
}

Why it works

TypeScript’s DOM types look up tag names in HTMLElementTagNameMap to pick the element type ("input" maps to HTMLInputElement). The interface is open: a declare global block adds entries to it, and from then on querySelector, createElement and getElementsByTagName use your class for your tag. customElements.define registers the class at runtime; the map is the matching declaration for the checker, and the two are kept in step by hand.

The same thing in TypeScript

The TypeScript equivalent tab of the first example annotates the accessors and callbacks inline. In a .ts file the declare global block can sit in the same file as the class; in JavaScript it has to be in a declaration file.

What goes wrong

Without the map, the DOM types know nothing about "price-tag": createElement returns HTMLElement and querySelector returns Element, and neither has cents (TS2339):

page.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: page.js(3,6): error TS2339: Property 'cents' does not exist on type 'HTMLElement'. page.js(7,18): error TS2339: Property 'cents' does not exist on type 'Element'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. page.js(3,6): error TS2339: Property 'cents' does not exist on type 'HTMLElement'. page.js(7,18): error TS2339: Property 'cents' does not exist on type 'Element'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. page.js(3,6): error TS2339: Property 'cents' does not exist on type 'HTMLElement'. page.js(7,18): error TS2339: Property 'cents' does not exist on type 'Element'. checked 21 October 2026
const lamp = document.createElement("price-tag");
lamp.cents = 3900;error TS2339: Property 'cents' does not exist on type 'HTMLElement'.

const total = document.querySelector("price-tag");
if (total) total.cents = 4999;error TS2339: Property 'cents' does not exist on type 'Element'.
Without the tag name map, the element is a plain HTMLElement.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "lib": ["es2022", "dom", "dom.iterable"],
    "types": []
  },
  "include": ["*.js"]
}

Gotchas

  • Tag names need a hyphen. customElements.define throws for a name without one; the map accepts any string, so the runtime is the check here.
  • The .d.ts must be in the program. The examples include *.d.ts; a declaration file left out of include does nothing.
  • Attributes are strings. getAttribute returns string | null, which is why the cents getter converts it with Number and a fallback.

Questions

Do the lifecycle callbacks need JSDoc?

connectedCallback and disconnectedCallback take no arguments. attributeChangedCallback receives the attribute's name and its old and new values, each string | null; type them with @param, as in the example, or tsc reports implicit any under strict.

Can I use the element's class as a type elsewhere?

Yes: @param {PriceTag} tag, after importing the class (or @import { PriceTag } from "./price-tag.js" for the type alone).

Sources

Last reviewed 21 October 2026.