Typing Custom Elements in JSX and TSX: Per-Runtime Intrinsic Element Augmentation
HTMLElementTagNameMap makes document.querySelector('wfc-banner') type correctly and does nothing at all for <wfc-banner tone="warning" /> in a TSX file. JSX resolves unknown lowercase tags through an entirely separate interface — JSX.IntrinsicElements — and every JSX runtime declares its own. React’s is not Preact’s, Preact’s is not Solid’s, and React 19 changed the shape of its own again.
The result is a package that must augment several unrelated interfaces to be usable across the ecosystem, while shipping none of them unconditionally, because declaring an interface belonging to a framework the consumer has not installed is itself a compile error. This deep dive belongs to Types & the Custom Elements Manifest inside Distribution, Testing & Tooling.
Problem Statement: The Tag JSX Has Never Heard Of
import '@wfc/components/banner';
export function Toolbar() {
return (
<wfc-banner tone="warning" dismissible>
{/* ~~~~~~~~~~ Property 'wfc-banner' does not exist on type 'JSX.IntrinsicElements'. */}
Deployment is degraded.
</wfc-banner>
);
}
The package already augments HTMLElementTagNameMap, so the same component types perfectly in a .ts file. In .tsx it does not compile at all. The workaround that spreads through codebases is a cast to any:
const Banner = 'wfc-banner' as any;
return <Banner tone="warning">Deployment is degraded.</Banner>;
which compiles, discards every attribute check, and hides the second, subtler problem: even when the tag is accepted, JSX sets attributes, not properties, so an object-valued prop is stringified to [object Object] and an onBannerDismiss prop is not wired to anything.
Root-Cause Analysis
TypeScript type-checks a JSX element whose tag is a lowercase identifier by looking it up as a property of JSX.IntrinsicElements. That interface is not part of the DOM library; it is contributed by whichever JSX runtime the project configured through jsxImportSource or the legacy JSX global namespace. React declares it in react/jsx-runtime, Preact in preact/jsx-runtime, Solid in solid-js, and each declares a different property type for the same element.
Three consequences follow.
There is no universal augmentation. A package cannot declare “this tag exists in JSX” once. It must declare it per runtime, because the interface it needs to merge into lives inside that runtime’s own module namespace.
An unconditional declaration breaks non-users. Writing declare module 'react' { … } in a file that every consumer loads makes TypeScript try to resolve react for a consumer who has never installed it, producing an error in a project that does not use React at all. The conventional shape is therefore an optional entry point — @wfc/components/react — that only React consumers import.
Attributes are not properties. JSX in React 18 and earlier sets unknown props as attributes via setAttribute, so a prop whose value is an object, array, or function is stringified. React 19 changed this: it now sets a property when one exists on the element instance and falls back to an attribute otherwise, and it passes functions for on* props to addEventListener when the prop name does not match a known React event. That difference means the same JSX types can be correct on one React major and misleading on the other.
items?: Item[] in an intrinsic-element augmentation makes <wfc-list items={rows} /> compile, and on React 18 the element receives the attribute items="[object Object]". The type system says the code is correct and the runtime disagrees. On React 18, type only the props that are genuinely attributes — strings, numbers, and booleans — and pass anything richer through a ref, or ship a generated wrapper component that assigns properties.
The event side has the same split. React 18 has no mechanism for arbitrary custom events, so a onBannerDismiss prop does nothing; the correct pattern is a useEffect with addEventListener, which is the adapter described in bridging custom events to React. React 19 wires unknown on* props to addEventListener, using a lowercase-after-on convention, so onbanner-dismiss works and onBannerDismiss does not.
Production-Safe Pattern
Ship one optional entry point per runtime. Each declares only the props that runtime can actually deliver, so the type system and the runtime agree.
// src/jsx/react.d.ts — published as "@wfc/components/react"
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import type { WfcBanner, BannerDismissDetail } from '../banner.js';
/** Only attribute-safe values: React 18 stringifies anything else. */
interface WfcBannerAttributes {
tone?: 'info' | 'warning' | 'danger';
dismissible?: boolean | '';
/** React 19 wires unknown on* props to addEventListener. Lowercase after "on". */
'onbanner-dismiss'?: (event: CustomEvent<BannerDismissDetail>) => void;
ref?: React.Ref<WfcBanner>;
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'wfc-banner': DetailedHTMLProps<HTMLAttributes<WfcBanner>, WfcBanner> & WfcBannerAttributes;
}
}
}
// src/jsx/preact.d.ts — published as "@wfc/components/preact"
import type { JSX as PreactJSX } from 'preact';
import type { WfcBanner, BannerDismissDetail } from '../banner.js';
declare module 'preact' {
namespace JSX {
interface IntrinsicElements {
'wfc-banner': PreactJSX.HTMLAttributes<WfcBanner> & {
// Preact assigns properties when they exist, so richer values are safe.
tone?: 'info' | 'warning' | 'danger';
dismissible?: boolean;
onbanner-dismiss?: (event: CustomEvent<BannerDismissDetail>) => void;
};
}
}
}
{
"name": "@wfc/components",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./banner": { "types": "./dist/banner.d.ts", "default": "./dist/banner.js" },
"./react": { "types": "./dist/jsx/react.d.ts" },
"./preact": { "types": "./dist/jsx/preact.d.ts" },
"./solid": { "types": "./dist/jsx/solid.d.ts" }
},
"peerDependenciesMeta": {
"react": { "optional": true },
"preact": { "optional": true }
}
}
// Consumer, React 19.
import '@wfc/components/banner';
import '@wfc/components/react'; // types only — no runtime cost
export function Toolbar() {
return (
<wfc-banner
tone="warning"
dismissible
onbanner-dismiss={(event) => console.log(event.detail.reason)}
>
Deployment is degraded.
</wfc-banner>
);
}
// Consumer, React 18 — attributes only, so events and rich data go through a ref.
import { useEffect, useRef } from 'react';
import '@wfc/components/banner';
import '@wfc/components/react';
import type { WfcBanner } from '@wfc/components/banner';
export function Toolbar() {
const ref = useRef<WfcBanner>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const controller = new AbortController();
element.addEventListener(
'banner-dismiss',
(event) => console.log(event.detail.reason),
{ signal: controller.signal }
);
return () => controller.abort();
}, []);
return <wfc-banner ref={ref} tone="warning" dismissible>Deployment is degraded.</wfc-banner>;
}
Three properties make this shape work. The entry points are types-only — no default condition — so importing one adds nothing to the bundle. The peer dependencies are marked optional, so a Vue consumer installing the package is not told to install React. And each augmentation types only what its runtime can deliver, so @ts-expect-error in a type test genuinely catches a mismatch rather than being papered over by an over-permissive declaration.
Verification
// type-tests/react19.tsx — compiled with tsc --noEmit against the packed tarball.
import '@wfc/components/banner';
import '@wfc/components/react';
import type { WfcBanner } from '@wfc/components/banner';
export const ok = (
<wfc-banner tone="warning" dismissible onbanner-dismiss={(e) => {
const reason: 'user' | 'timeout' = e.detail.reason; // detail is typed
void reason;
}} />
);
// The union is enforced.
// @ts-expect-error tone only accepts the documented values
export const bad = <wfc-banner tone="purple" />;
// A ref resolves to the element class, not HTMLElement.
export function WithRef() {
const ref = React.useRef<WfcBanner>(null);
React.useEffect(() => { ref.current?.dismiss('timeout'); }, []);
return <wfc-banner ref={ref} />;
}
Run the same file against each supported runtime in CI, each in its own scratch project installed from the packed tarball. Two failures matter and both are silent otherwise: a runtime whose augmentation was never exported (the tag stops compiling) and a runtime whose augmentation is too permissive (the @ts-expect-error line starts compiling and the build fails, which is the point).
A runtime check is worth pairing with the type check for React 18, because the type system cannot express “this stringifies”. Render the component in a test, then assert element.getAttribute('items') === null and element.items is the array you passed — if the attribute is [object Object], the prop was typed as something JSX cannot deliver.
When to Use vs When to Avoid
| Consumer setup | Approach |
|---|---|
| React 19 | Intrinsic augmentation with on* event props and rich values |
| React 18 | Intrinsic augmentation with attribute-safe props only; ref for the rest |
| Preact | Intrinsic augmentation; properties and events both work |
| Solid | Intrinsic augmentation; prop: and on: namespaces remove ambiguity |
| Vue | No JSX augmentation needed for templates; configure isCustomElement |
| Angular | No JSX; generate wrapper directives from the manifest |
| Plain TypeScript, no JSX | HTMLElementTagNameMap alone is sufficient |
| Many runtimes to support | Generate the augmentations from the manifest |
The last row is where this stops being hand-written work. Every field in these declarations — tag name, attribute names and unions, event names and detail types — already exists in the custom elements manifest. Generating the per-runtime .d.ts files from it means adding a component updates every runtime’s typings in one step, and no augmentation can drift from the component it describes.
Frequently Asked Questions
Why does HTMLElementTagNameMap not fix JSX?
Because JSX resolves lowercase tags through JSX.IntrinsicElements, an interface contributed by the JSX runtime, not through the DOM library’s tag name map. They are unrelated lookups and both need augmenting.
Can I ship one augmentation for every framework?
No. Each runtime declares its own interface inside its own module, so the declarations must live in separate, optional entry points that only consumers of that runtime import — otherwise a project without React gets an unresolvable module error.
Why does my object prop arrive as [object Object]?
On React 18, JSX sets unknown props as attributes, and attributes are strings. Type only attribute-safe values for that runtime and assign richer values through a ref, or ship a generated wrapper component that sets properties.
What is the correct event prop name in React 19?
Lowercase after on, matching the dispatched event name: onbanner-dismiss for an event named banner-dismiss. Camel-cased names are treated as ordinary props and are not wired to addEventListener.
Related
- Types & the Custom Elements Manifest — the parent topic on declaration artefacts.
- Distribution, Testing & Tooling — the section this deep dive belongs to.
- Declaring HTMLElementTagNameMap Entries — the DOM half of the same problem.
- Generating a Custom Elements Manifest — the source these augmentations should be generated from.
- Bridging Custom Events to React — the runtime adapter for React 18.