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:
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);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:
import type { PriceTag } from "./price-tag.js";
declare global {
interface HTMLElementTagNameMap {
"price-tag": PriceTag;
}
}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:
const total = document.querySelector("price-tag");
if (total) total.cents = 4999;
const lamp = document.createElement("price-tag");
lamp.cents = 3900;
document.body.append(lamp);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):
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'.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.definethrows for a name without one; the map accepts any string, so the runtime is the check here. - The
.d.tsmust be in the program. The examples include*.d.ts; a declaration file left out ofincludedoes nothing. - Attributes are strings.
getAttributereturnsstring | null, which is why thecentsgetter converts it withNumberand 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
- Using custom elements · MDN Web Docs
- Declaration Merging (global augmentation) · TypeScript handbook
Last reviewed 21 October 2026.