typed-html-templates
v1.0.3
Published
HTM-compatible html tagged templates with strong TypeScript hole typing and DOM-aware inference.
Maintainers
Readme
typed-html-templates
Type-safe HTM templates on top of htm.
This package keeps HTM runtime semantics (htm.bind(h)) and adds compile-time type-safety for interpolation holes (${...}) using a type-level parser/state machine.
Why this exists
Ryan Carniato described the problem well:
"I've been looking at
htmltagged template literals again and man the state of things sucks. I'd be so happy if @_developit'shtmwas the accepted format and we could just get typing going, but as usual there are competing standards and in a lot of ways it makes sense.
We have WC folks who have a strong voice in standards that don't care about anything beyond WCs (why should they) but unfortunately WCs are severely limited in a universal sense, so can never be the actual complete answer.
We have TS that doesn't want to go through implementing a specific thing like JSX again so unless standards forces their hands they probably aren't going to move.
The options for representing tags are either untypable slots, inline case sensitive (goes against standard case insensitivity), or bulky beyond reasonable usage(call create element yourself).
I would appeal to WC folks to support some exotic syntax. But honestly if I were them I wouldn't want to. But then again they have no issue proposing their own exotic syntax (like
.,@, and?) which IMHO should only be included if HTML supports them. So I get this does devolve into people trying to standardize their own framework. But the TS need is real. A real conundrum."
This package aims to be that "get typing going" layer for HTM-compatible templates.
Install
npm install typed-html-templatesQuick start
import html from 'typed-html-templates';
const node = html`<div id=${'hello'}>${'world'}</div>`;html returns Node | Node[] because HTM supports multiple root nodes.
If you want a strict single-root result, use single(...) or bindSingle(h):
import html, { single } from 'typed-html-templates';
const node = single(html`<div id=${'hello'}>${'world'}</div>`);You can also use the template(parts, ...values) helper when you want explicit tuples that preserve inference:
import html, { template } from 'typed-html-templates';
const result = html(...template(['<input value=', ' checked=', ' />'] as const, 'ok', true));If you prefer not to write as const at call sites, use asConst(...):
import html, { asConst, template } from 'typed-html-templates';
const result = html(...template(asConst(['<input value=', ' />']), 'ok'));Aliases are included for readability in different contexts:
template.from(...)template.dom(...)template.one(...)
You can also use html.params(...) / html.from(...) (and strict equivalents) for a plain variadic-strings style:
const renderInput = html.params('<input value=', ' />');
const node = renderInput('ok');params/from are primarily for root-shape ergonomics and readability. For strongest hole-level type safety, prefer template(...) + spread into html(...).
For editor DX, template(...) usually gives the strongest root-tag inference (for example, input -> HTMLInputElement).
For static intrinsic roots, the default node type also carries a typed element? field:
<div ...>->HTMLDivElement | undefined<input ...>->HTMLInputElement | undefined
Safety guarantees
When template parts are statically known (tagged templates and template(...) helper), the library enforces:
- hole-kind checking (tag vs attr-value vs spread vs child)
- intrinsic attr typing (DOM map by default)
- dynamic component prop typing (
<${Comp} ...>) - spread shape checking (
...${props}) - event handler typing for
on*attrs
This means invalid hole values fail at compile time (via TypeScript), not just at runtime.
Strict mode
Use bindStrict(h) when you want unknown attrs to fail for known intrinsic tags and components:
import { bindStrict, template } from 'typed-html-templates';
const h = (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => ({
type,
props,
children
});
const html = bindStrict(h);
html`<input value=${'ok'} />`; // ok
// @ts-expect-error unknown intrinsic attr
html(...template(['<input notARealProp=', ' />'] as const, 'x'));
const Card = (_props: { title: string }) => null;
// @ts-expect-error unknown component prop
html(...template(['<', ' nope=', ' />'] as const, Card, 'x'));If you want strict mode with the default binding, use strictHtml directly:
import { strictHtml, template } from 'typed-html-templates';
strictHtml`<input value=${'ok'} />`;
// @ts-expect-error unknown intrinsic attr in strict mode
strictHtml(...template.one(['<input notARealProp=', ' />'] as const, 'x'));Inference notes
There are two kinds of inference in play:
- Hole type inference (what each
${...}must be) - Root node narrowing (whether result becomes
Node<"input", ...>vs genericNode<string, ...>)
Hole type inference is strong in both styles:
htmltagged template formhtml(...template(parts, ...values))tuple helper form
Root node narrowing is strongest with template(...) tuple form:
const node = single(html(...template(['<input value=', ' />'] as const, 'ok')));
// node.type -> "input"
// node.element -> HTMLInputElement | undefinedIn plain tagged-template syntax, TypeScript may widen root tag information depending on compiler inference limits:
const node = single(html`<input value=${'ok'} />`);
// node.type may be `string`
// node.element may be `HTMLElement | undefined`This is a TypeScript inference limitation around tagged-template literal preservation, not a runtime behavior difference.
The default export is bind(defaultH), where defaultH returns:
{ type, props, children }Use with your renderer
Like HTM, this package is renderer-agnostic:
import { bind } from 'typed-html-templates';
const h = (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => ({
type,
props,
children
});
const html = bind(h);Runtime behavior is delegated directly to htm.bind(h).
Type-safety model: interpolation holes
The type system parses the template string around each ${...} and infers what that hole means from context.
Hole kinds
- Tag hole:
<${X} ...> - Attribute value hole:
<div id=${X} /> - Spread hole:
<div ...${X} /> - Child hole:
<div>${X}</div>
Each hole gets a different expected type.
1) Tag holes
In <${X} />, X must be a valid tag target:
- intrinsic tag name (
'div','button', etc.) - component function/class
const Comp = (_props: { ok: boolean }) => null;
html`<${'div'} />`; // ok
html`<${Comp} ok=${true} />`; // ok
// @ts-expect-error invalid tag target
html`<${{ nope: true }} />`;2) Attribute value holes
In attr=${X}, X is validated against the active tag and attribute.
- intrinsic tags use intrinsic map props
- dynamic component tags use inferred component props
- unknown attrs fall back to primitive values
const Card = (_props: { title: string; featured?: boolean }) => null;
html`<input value=${'ok'} checked=${true} />`; // ok
// @ts-expect-error input.value should be string
html`<input value=${123} />`;
html`<${Card} title=${'Hello'} featured=${true} />`; // ok
// @ts-expect-error title should be string
html`<${Card} title=${123} />`;3) Spread holes
In ...${X}, X must be object-like:
- intrinsic tag:
Partial<IntrinsicProps> - dynamic component tag:
Partial<ComponentProps> - allows
null | undefined
const Card = (_props: { title: string; featured?: boolean }) => null;
html`<div ...${{ id: 'a' }} />`; // ok
// @ts-expect-error wrong intrinsic prop type
html`<div ...${{ id: 1 }} />`;
html`<${Card} ...${{ title: 'ok' }} />`; // ok
// @ts-expect-error wrong component prop type
html`<${Card} ...${{ title: 123 }} />`;4) Child holes
In child position, values are constrained to ChildValue:
- primitives (
string | number | boolean | null | undefined) - nested child arrays
- object-like values
html`<div>${'text'}</div>`;
html`<div>${['a', 1, { nested: true }]}</div>`;
// @ts-expect-error symbols are not valid children
html`<div>${Symbol('x')}</div>`;Built-in DOM intrinsic typing
By default, bind(h) uses DOMIntrinsicElements derived from HTMLElementTagNameMap.
This gives out-of-the-box typing for common DOM properties, including case-insensitive attr lookup for matching (tabindex resolves against tabIndex).
import { bind } from 'typed-html-templates';
const h = (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => ({
type,
props,
children
});
const html = bind(h);
html`<input value=${'x'} checked=${true} />`;
html`<div tabindex=${1} />`;
// @ts-expect-error tabindex should be number
html`<div tabindex=${'1'} />`;Custom intrinsic map
You can replace DOM defaults with your own intrinsic map:
import { bind } from 'typed-html-templates';
type Intrinsics = {
div: {
id?: string;
draggable?: boolean;
};
button: {
disabled?: boolean;
title?: string;
};
};
const h = (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => ({
type,
props,
children
});
const html = bind<typeof h, Intrinsics>(h);
html`<div id=${'ok'} draggable=${true} />`;
// @ts-expect-error id should be string
html`<div id=${123} />`;HTM compatibility
- Runtime rendering/parsing is from
htm - Multiple roots, component tags, spread props, boolean attrs, etc. follow HTM runtime behavior
- This package focuses on static typing of interpolation holes
API
default->htmlbound to a default object-returninghstrictHtml-> strict-mode default export (unknown attrs rejected in known contexts)bind(h)-> typed HTM-compatible tag functionbindStrict(h)-> typed HTM-compatible tag function that rejects unknown attrs in known contextsbindSingle(h)-> typed HTM-compatible tag that enforces exactly one root at runtimebindSingleStrict(h)-> strict + single-root runtime enforcementsingle(result)-> unwraps aresult | result[], throws if root count is not exactly oneasConst(value)-> const-generic identity helper for literal-preserving inferencetemplate(parts, ...values)-> helper that preserves literal template parts and value tuple inferencetemplate.from/dom/one-> aliases oftemplate(...)for intent/readabilityDOMIntrinsicElements-> built-in intrinsic map fromHTMLElementTagNameMapInferComponentProps<T>-> extract component propsComponentType<Props, Result>-> component function/class shape
