Types & the Custom Elements Manifest

A published component library ships two artefacts that most teams get wrong: a machine-readable description of what the components are, and type declarations that make them usable from TypeScript. Without the first, editors offer no autocomplete for tag names, attributes, or events, and documentation sites have to be written by hand. Without the second, every consumer writing document.querySelector('my-card').open = true gets any, and every JSX consumer gets a red squiggle under a tag the compiler has never heard of.

Both problems have standard answers. The Custom Elements Manifest is a community-standard JSON schema describing a package’s custom elements — their tags, attributes, properties, events, slots, parts, and CSS custom properties — generated from source rather than maintained by hand. TypeScript’s HTMLElementTagNameMap and the JSX intrinsic-element interfaces are how that same information reaches the compiler. This is the parent topic for the declaration side of publishing, inside Distribution, Testing & Tooling.

One source of truth feeding editors, compilers and documentation Component source with JSDoc is analysed into a custom elements manifest, which is then transformed into type declarations, editor data files and generated documentation. component source classes + JSDoc annotations custom-elements.json generated, never hand-edited TypeScript declarations HTMLElementTagNameMap JSX intrinsic elements editor data vscode.html-custom-data.json tag and attribute completion generated docs API tables per component always in sync with source
Standards grounding. The Custom Elements Manifest is a community specification maintained in the open at custom-elements-manifest, versioned by a schemaVersion field in every document. It is not a W3C or WHATWG standard, but it is the de facto interchange format consumed by editor tooling, documentation generators, and framework wrappers across the ecosystem. The type side rests on TypeScript's own declaration merging: HTMLElementTagNameMap is an interface in the DOM library that querySelector, createElement, and closest are generically keyed on, and augmenting it is a documented pattern rather than a hack.

What the Manifest Describes

A manifest is a single JSON document with a schemaVersion, a list of modules, and for each module a list of declarations. A custom element declaration carries the fields a consumer actually needs, and the value of the format is that all of them come from one place.

Field Describes Typical source
tagName The registered tag customElements.define call
attributes Name, type, default, description observedAttributes + JSDoc @attr
members Public properties and methods Class fields and methods
events Name and detail type JSDoc @fires
slots Name and purpose JSDoc @slot
cssParts Exposed parts JSDoc @csspart
cssProperties Themeable custom properties JSDoc @cssprop
superclass Base class and its package extends clause
deprecated Deprecation notice JSDoc @deprecated

Four of those rows are the ones hand-written documentation always drifts on. Slots, parts, CSS properties, and events are the component’s public contract and none of them appear in the type system — TypeScript has no concept of a slot. The manifest is therefore not an alternative to types; it covers a different, larger surface.

Generation is a build step. The reference analyser reads the source, follows the class hierarchy, and emits the JSON; annotations fill in what static analysis cannot infer, such as the payload shape of a custom event. The rule that keeps the artefact trustworthy is simple: never hand-edit the output. A manifest committed as generated output and regenerated in CI is a fact about the code; one edited by hand is a second thing to keep in sync.

Wiring the Manifest Into a Package

{
  "name": "@wfc/components",
  "version": "3.2.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "customElements": "custom-elements.json",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./card": {
      "types": "./dist/card.d.ts",
      "default": "./dist/card.js"
    },
    "./custom-elements.json": "./custom-elements.json"
  },
  "files": ["dist", "custom-elements.json"],
  "sideEffects": ["./dist/*/define.js"],
  "scripts": {
    "analyze": "custom-elements-manifest analyze --litelement --outdir .",
    "prepack": "npm run build && npm run analyze"
  }
}

Three fields carry the integration. customElements is the conventional pointer editors and documentation tools look for — it is not part of the npm schema, but it is the agreed location. The explicit exports entry for the JSON matters because a package with an exports map blocks deep imports by default, so a consumer’s tooling cannot read the manifest unless it is exported. And files must include it, or npm pack ships a package whose customElements field points at nothing.

The sideEffects entry is subtler and is covered in tree-shaking side-effect-free libraries: a module whose only job is to call customElements.define is a side effect, and marking the whole package side-effect-free deletes the registration.

Debugging Pitfall — the manifest that shipped stale. Generating the manifest in a prepublishOnly script that runs after the version bump means the published JSON describes the previous commit whenever the build and the analysis disagree about ordering. Worse, running it only locally means whoever publishes from a different machine ships a manifest generated from a different dependency tree. Generate it in prepack, commit the output, and add a CI check that regenerating produces no diff — the same guarantee a lockfile gives.

Annotating Source So the Manifest Is Complete

Static analysis finds classes, fields, and define calls. It cannot infer what a slot is for, what an event’s detail contains, or which custom properties a stylesheet reads. Those come from JSDoc, and the annotation vocabulary is small.

/**
 * A dismissible notification banner.
 *
 * @tagname wfc-banner
 * @summary Announces a transient message and can be dismissed by the reader.
 *
 * @attr {('info'|'warning'|'danger')} tone - Visual tone. Defaults to `info`.
 * @attr {boolean} dismissible - Renders a close button when present.
 *
 * @fires {CustomEvent<{ reason: 'user' | 'timeout' }>} banner-dismiss -
 *   Dispatched when the banner closes. Composed and bubbling.
 *
 * @slot - The banner message.
 * @slot icon - Replaces the default tone icon.
 *
 * @csspart container - The outer surface.
 * @csspart close - The dismiss button.
 *
 * @cssprop [--wfc-banner-radius=10px] - Corner radius of the surface.
 * @cssprop [--wfc-banner-gap=0.75rem] - Space between icon and message.
 */
class WfcBanner extends HTMLElement {
  static observedAttributes = ['tone', 'dismissible'];

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.attachShadow({ mode: 'open' }).innerHTML = `
      <style>
        :host { display: block; border-radius: var(--wfc-banner-radius, 10px); }
        [part~="container"] { display: flex; gap: var(--wfc-banner-gap, 0.75rem); padding: 0.75rem 1rem; }
      </style>
      <div part="container">
        <slot name="icon">&#9432;</slot>
        <slot></slot>
        <button part="close" type="button" hidden aria-label="Dismiss">&#215;</button>
      </div>`;
  }

  /** The banner's visual tone. Reflected to the `tone` attribute. */
  get tone() { return this.getAttribute('tone') ?? 'info'; }
  set tone(value) { this.setAttribute('tone', value); }

  /** Closes the banner and announces why. */
  dismiss(reason = 'user') {
    this.hidden = true;
    this.dispatchEvent(new CustomEvent('banner-dismiss', {
      detail: { reason }, bubbles: true, composed: true
    }));
  }
}
customElements.define('wfc-banner', WfcBanner);
export { WfcBanner };

Every annotation above becomes a field in the manifest, and from there an entry in generated documentation, an editor completion, and — for events and properties — a type. The discipline worth adopting is to treat a missing annotation as a missing feature: if a slot is not documented with @slot, it does not exist as far as consumers are concerned.

Which parts of a component's contract each artefact can express TypeScript declarations cover properties, methods and events but not slots, parts or custom properties, while the manifest covers all of them, which is why both are needed. The two artefacts cover different halves of the contract tag name types manifest properties, methods types manifest events types (via event map) manifest attributes only in JSX typings manifest slots no representation manifest parts no representation manifest CSS custom properties no representation manifest

The Type Declarations That Make a Component Usable

Types cover a narrower surface than the manifest, but the parts they cover are the ones consumers hit first. Three augmentations turn a package from “works, untyped” into “works, with autocomplete”.

// wfc-banner.d.ts — shipped alongside the JavaScript.

export interface BannerDismissDetail {
  reason: 'user' | 'timeout';
}

export declare class WfcBanner extends HTMLElement {
  tone: 'info' | 'warning' | 'danger';
  dismissible: boolean;
  dismiss(reason?: BannerDismissDetail['reason']): void;

  // Typed listener overloads: an event map alone does not reach addEventListener
  // on a subclass, so the overloads are declared explicitly.
  addEventListener<K extends keyof WfcBannerEventMap>(
    type: K,
    listener: (this: WfcBanner, ev: WfcBannerEventMap[K]) => unknown,
    options?: boolean | AddEventListenerOptions
  ): void;
  addEventListener(
    type: string,
    listener: EventListenerOrEventListenerObject,
    options?: boolean | AddEventListenerOptions
  ): void;
}

export interface WfcBannerEventMap extends HTMLElementEventMap {
  'banner-dismiss': CustomEvent<BannerDismissDetail>;
}

// 1. querySelector('wfc-banner') and createElement('wfc-banner') resolve to the class.
declare global {
  interface HTMLElementTagNameMap {
    'wfc-banner': WfcBanner;
  }
}
// Consumer code — every line below is now checked.
const banner = document.querySelector('wfc-banner');   // WfcBanner | null
banner?.addEventListener('banner-dismiss', (event) => {
  console.log(event.detail.reason);                    // 'user' | 'timeout'
});
banner!.tone = 'warning';                              // ok
banner!.tone = 'purple';                               // compile error

The event-map overloads are the piece teams most often omit, and their absence is why event.detail so often ends up any in consumer code. HTMLElementEventMap is consulted for HTMLElement, not for a subclass, so a component that dispatches custom events must declare the overloads on its own class for the payload type to reach a listener.

The third augmentation — JSX intrinsic elements — is per-runtime rather than universal, because React, Preact, Solid, and the classic JSX transform each declare their own interface. Shipping all of them in one declaration file creates errors for consumers who have only one runtime installed, so the conventional shape is a separate optional entry point the consumer opts into, described in typing custom elements in JSX and TSX.

Editor Data: Completion Without a TypeScript Project

A large share of consumers write plain HTML, not TypeScript, and get no benefit from declarations. For them the useful artefact is a custom data file — a small JSON format editors read to offer tag and attribute completion in .html files. It is generated from the manifest rather than authored:

{
  "version": 1.1,
  "tags": [
    {
      "name": "wfc-banner",
      "description": "A dismissible notification banner.",
      "attributes": [
        {
          "name": "tone",
          "description": "Visual tone. Defaults to info.",
          "values": [{ "name": "info" }, { "name": "warning" }, { "name": "danger" }]
        },
        { "name": "dismissible", "description": "Renders a close button when present." }
      ],
      "references": [
        { "name": "Documentation", "url": "https://example.com/components/banner" }
      ]
    }
  ]
}

Consumers point their editor at it once, and every attribute on every component completes with its allowed values. Because the file is derived from the manifest, it is correct by construction — and because it is a build artefact, it costs nothing to keep in sync. This is the cheapest documentation a component library can ship, and it is the one that reaches the consumers least likely to read a documentation site. The mechanics are covered in publishing HTML data for editor autocomplete.

Common Failure Modes & Debugging Steps

1. Editors offer no completion for the tags. The customElements field is missing from package.json, or the manifest is not in files, or an exports map blocks reading it. Verify with npm pack --dry-run and confirm the JSON appears in the listing, then check that node -e "require.resolve('@wfc/components/custom-elements.json')" resolves.

2. TypeScript reports the element as HTMLElement. The package ships types for the class but never augments HTMLElementTagNameMap, so querySelector('wfc-banner') falls back to the generic signature. The fix is a declaration-merging block, covered in declaring HTMLElementTagNameMap entries.

3. JSX rejects the tag. React and other JSX runtimes type intrinsic elements from their own interfaces; a custom element is unknown until the package augments them. This differs between React 18 and 19 and between JSX runtimes, and is covered in typing custom elements in JSX and TSX.

4. The manifest is missing events, slots, or parts. Static analysis cannot infer them, so they only appear if annotated. Run the analyser with its verbose flag and diff the declaration list against the component’s documented contract; anything absent is an annotation that was never written.

5. Two packages disagree about the same tag. A monorepo publishing both a base package and a bundle can register the same tag twice and declare it twice, so a consumer importing both gets a duplicate-definition error at runtime and a duplicate-identifier error at compile time. Give exactly one package ownership of each tag and have the other re-export.

6. A monorepo consumer sees types that an external consumer does not. Workspace path mapping resolves declarations straight from source, bypassing the exports map entirely, so a package with a broken or misordered types condition types perfectly inside its own repository and not at all outside it. The only reliable check installs the packed tarball into a scratch project and compiles there.

Framework Interop

Framework wrapper generators consume the manifest directly. Given attributes, properties, and events, they can emit a React component that maps props to properties and on* handlers to addEventListener — solving the two problems described in bridging custom events to React. Vue and Angular integrations use the same input for their own compiler configuration and template type checking.

That is the strongest practical argument for investing in the manifest: it is the input format for tooling you have not written. A library with an accurate manifest gets editor support, documentation, and framework wrappers as derived artefacts; one without it needs each of those maintained by hand.

The generated wrappers also carry the manifest’s mistakes, which is worth stating plainly. A wrapper generator reading an event whose detail type was annotated as a bare CustomEvent emits a handler prop typed as any, and a wrapper reading an attribute whose union was never annotated emits a string prop that accepts nonsense. Annotation quality therefore propagates directly into consumer type safety two tools downstream — an argument for treating @fires and @attr types as production code rather than comments.

Angular deserves a specific note because its template compiler is stricter than the others. With CUSTOM_ELEMENTS_SCHEMA registered, unknown elements and attributes are accepted silently, which removes the errors and also removes the checking; without it, every custom element is a template error. Neither is satisfying, and the practical middle ground is to generate wrapper directives from the manifest so the compiler sees real inputs and outputs — the same generation step described in projecting Angular content into web components.

Performance & Distribution Implications

The manifest is a build artefact, not a runtime dependency — it must never be imported by application code, and a component that reads its own manifest at runtime has made a documentation file into a shipping payload. Keep it out of the module graph entirely.

Manifest size is worth a glance for large libraries. A package with two hundred components produces a JSON document measured in hundreds of kilobytes, which is irrelevant to runtime but noticeable to editors that parse it on every project open. Splitting the manifest per entry point is possible and rarely worth it; keeping annotations terse is the cheaper lever, and duplicate prose that belongs in the documentation site should not be inlined into @summary blocks.

Type declarations have a subtler cost. A package whose .d.ts files import from deep paths forces the consumer’s compiler to resolve those paths, which slows type checking across the whole project and breaks under "moduleResolution": "bundler" if the exports map does not declare them. Emit declarations alongside the JavaScript, give every export condition a types entry first in the condition order, and verify the result with a package-linting tool rather than by hand — the same rigour applied to runtime entry points in configuring package.json exports.

Where each artefact is produced and verified in the release pipeline The manifest and declarations are generated during pack, checked for drift in continuous integration, and verified against the published tarball before release. Generate, verify, then publish — never the other order build dist/*.js dist/*.d.ts analyze custom-elements.json html-custom-data.json verify in CI regenerate → no diff pack → files present publish tarball describes the code inside it Analysing before the build describes source that was never shipped; analysing after publish describes nothing at all. The no-diff check is what makes the committed artefact a fact about the code rather than a second thing to maintain.

Frequently Asked Questions

Is the Custom Elements Manifest a W3C standard?

No. It is a community specification with an explicit schemaVersion field, maintained in the open and adopted across the ecosystem as the interchange format for editor tooling, documentation generators, and framework wrapper generators.

Should the generated manifest be committed to version control?

Yes, and CI should verify that regenerating it produces no diff. Committing it makes the artefact reviewable and lets consumers of a git dependency read it; the CI check is what stops it drifting from the source.

Do types replace the need for a manifest?

No. TypeScript has no representation for slots, parts, or CSS custom properties, and those are a large part of a component’s public contract. The two artefacts cover different halves of the surface.

Where should the manifest live in the published package?

At the package root as custom-elements.json, referenced by a customElements field in package.json, listed in files, and given an explicit entry in the exports map so tooling can actually read it.