The core problem this library exists to solve is not writing HTML or CSS — it is changing them. When the structure of the HTML changes and the CSS that targeted the old structure is forgotten, nothing fails: selectors silently stop matching, dead rules accumulate, and the stylesheet drifts away from the markup it describes. No mainstream tool catches this. CSS Modules can prove a class exists; nothing proves the structure a selector assumes still exists.
Here, a component's CSS is keyed off the component's own structure:
- children are named (
innerHTML: { title: ... }), so styles have stable structural handles instead of positional/tag selectors, - child selectors (
> title) are typed as keys of that structure, - so renaming, removing, or moving a child makes the CSS that targeted it a compile error at the same call site.
Change the structure and the stale CSS is not a runtime surprise or a visual bug — it is a red squiggle.
Every rule is enforced twice, by design:
- Type level —
ValidateComponentStructurerewrites invalid structures into error types. This is the interactive wall: humans get it in the editor, AI agents get it fromtsc. - Runtime —
validateComponentNodewalks the real object and throws with a structured message. This is the backstop wall: it holds even when the type layer is bypassed (as any, generated code, data from outside the compiler's view).
The two layers are required to agree; divergence is treated as a bug (see "Known gaps" below for the one currently known divergence).
These were established by direct experiment (2026-07), not by intention.
Semantics used: h1 > b is valid standalone, but a does not allow b, so
a > h1 > b must fail the inheritance intersection (a.innerHTML ∩ h1.innerHTML).
| Scenario | Type level | Runtime |
|---|---|---|
Inline a > h1 > b |
rejected | rejected |
h1 > b built by its own createComponent, then embedded under <a> |
rejected at the parent call | rejected |
Factory with widened return type (BaseComponentStructure) embedded under <a> |
rejected — fails closed | rejected |
Parent css > heading targeting a prebuilt child |
accepted | accepted |
Parent css > headnig (typo) |
rejected | accepted — known gap |
Key consequences:
- Composition does not weaken validation.
createComponentreturns the full literal type, so a parent re-validates the entire subtree under its own ancestral context, and the runtime walks the whole tree at the parent call. Splitting components across files keeps every guarantee. - Widening fails closed, not open. A component whose type has been widened cannot be embedded at all — the type system refuses rather than trusting it. Safe, but it imposes the constraint below.
Structural typing at this depth is only viable if it stays cheap. Measured
against the common and full config variations:
tsccost is linear: ~4.8K instantiations / ~5ms check time per component (1 → 400 components: 124K → 2.05M instantiations, 0.36s → 2.4s).- Editor (tsserver): warm completions inside a
cssblock 8–18ms; completion immediately after an edit ~210ms (common) / ~370ms (full); full-file semantic check of a 5-component file ~43ms.
Practical rule: keep files to a handful of components each and editor latency is a non-issue. The post-edit constant grows with config breadth, not component size.
- Let inference flow. Do not annotate component factory return types with
widened types (
BaseComponentStructure, hand-written interfaces). The literal type is the validation artifact; widening it makes the component unusable as a child (deliberately). - Name children meaningfully. The
innerHTMLkeys are the selector namespace — they appear in css blocks (> title) and in rendered scoped CSS (cid-title).
Tracked in TASK.md:
- Runtime does not yet validate
> childNamecss selector keys againstinnerHTMLkeys (type level does). Until fixed, a typo'd child selector can survive if the type layer is bypassed. - Runtime does not yet validate css property values during render
(
renderComponent), only duringcreateComponent.
- Reimplementing the full CSS value grammar in the type system. The moat is
the structure↔style coupling and the closed-world registries, not proving
calc(100% - 20px)correct at the type level. Grammar features are added only where they serve the coupling (e.g.var()resolution against the property registry). - Being a rendering framework. Output is plain HTML and CSS strings; how they are served is the caller's concern.