HXX is a compile‑time template system for writing Phoenix HEEx in Haxe with strong typing and clear errors. It expands to idiomatic ~H at compile time and integrates with Phoenix conventions (assigns, components, directives) without adding runtime cost.
This guide documents the interface, syntax, and developer UX, and compares HXX to Coconut UI (Haxe) and TSX/JSX (TypeScript/React) in terms of syntax and functionality.
- HEEx parity: Generate HEEx that looks hand‑written and follows standard Phoenix patterns.
- Typed authoring: Catch invalid attributes, values, and assigns usage at compile time.
- No runtime tax: All checks run at compile time; output is plain
~H. - No app coupling: Never rely on project‑specific names; validation is API/shape‑driven.
- No Dynamic widening: Keep precise types; fix transforms instead of using Dynamic.
See also:
- 02-user-guide/INLINE_MARKUP.md – Inline markup authoring (typed-first)
- 02-user-guide/ESCAPE_HATCHES.md – Raw HEEx escape hatches and when (not) to use them
- 05-architecture/HXX_ARCHITECTURE.md – Technical pipeline
- 07-patterns/PHOENIX_LIVEVIEW_PATTERNS.md – Practical LiveView authoring patterns
- Typed-first entrypoint: Haxe inline markup literals (
return <div>...</div>) are rewritten into a canonical template entrypoint (phoenix.hxx.HeexTemplate.root/1), which the builder lowers to~H. - TSX control tags:
<if ${cond}>/<for ${item in items}>are parsed at macro-time into a typed template AST and lowered to HEExif/forblocks. - Typed spread attrs: tag-position attrs expressions (e.g.
<section {assigns.attrs}>) lower to HEEx spread attrs (<section {@attrs}>). - Typed loop helper:
phoenix.hxx.HeexTemplate.for_each(items, (item) -> <li>...</li>)remains available when expression-style composition is preferred. Short alias:phoenix.hxx.H.each(...)(orphoenix.hxx.H.for_each(...)). - Template strings (legacy/migration):
hxx('...')/HXX.block('...')are supported in balanced mode, but they are string-rewritten + linted (template-local markers are not Haxe-typed). - Layered modes:
@:hxx_mode("tsx"|"balanced"|"metal")controls how strict the template authoring surface is. In TSX mode, legacy string templates and untyped markers are rejected.tsxis named after TypeScript JSX-style authoring: strict, typed expression embedding.balancedis migration-friendly and accepts legacy string-template forms.metalis intentionally close to raw HEEx and should be rare.
Inline markup notes:
- Root tag must be a valid XML name (Haxe lexer rule).
- Phoenix dot-components like
<.form>cannot be the root; wrap them in a normal element (e.g.<div>...</div>). - Inline markup parses
${...}segments into real Haxe expressions (Context.parseInlineString), so syntax + types are checked by the Haxe typer.
Defaults / opt-out:
- Inline markup rewrite is enabled by default for Phoenix-facing modules (
@:liveview,@:component, etc.); opt out with-D hxx_no_inline_markupor@:hxx_no_inline_markup. - Force-enable per-module with
@:hxx_inline_markup(useful for non-Phoenix modules). - Legacy escape hatch:
@:hxx_legacyforces the older rewrite-to-HXX.hxx("...")behavior for that class.
Interpolation note:
- In inline markup,
${...}segments are parsed into real Haxe expressions (Context.parseInlineString) and are fully type-checked by Haxe. - In template strings (
hxx('...')), markers like#{...}and<if { ... }>/<for { ... }>are rewritten + linted, but do not provide Haxe-typed expressions. Prefer inline markup for typed authoring.
Example:
import phoenix.hxx.HeexTemplate;
class View {
public static function render(assigns: { name:String, online:Bool }): String {
return <div class=${assigns.online ? "online" : "offline"}>
Hello, ${assigns.name}!
</div>;
}
}Generates (Elixir):
def render(assigns) do
~H"""
<div class={if @online, do: "online", else: "offline"}>
Hello, <%= @name %>!
</div>
"""
end- Inline markup (typed):
${haxeExpr}splices are real Haxe expressions and lower to<%= ... %>(andassigns.*is mapped to@*). - Template strings (legacy):
#{expr}and${expr}are string-level markers rewritten into HEEx; they are convenient for migration but are not Haxe-typed. - Attributes:
attr={expr}stays asattr={expr}(HEEx attribute expressions). When written via${...}, HXX normalizes to{...}. - Spread attrs:
{assigns.attrs}(or{@assigns.attrs}) in tag position lower to HEEx spread attrs{@attrs}.
- Raw
<% ... %>blocks insidehxx('...')/HXX.hxx('...')are disallowed by default. - Opt-in escape hatch: add
@:allow_heexto the enclosing function or class (or compile with-D hxx_allow_raw_heex). - Metal mode:
@:hxx_mode("metal")allows raw<% ... %>without@:allow_heex(discouraged; emits warnings).
render(assigns: AssignsType)is required;@fieldreferences are validated againstAssignsType.- Linter reports:
- Unknown fields:
@sort_byy - Obvious literal kind mismatches:
@count == "zero"whencount:Int
- Unknown fields:
- Attribute names in Haxe may be camelCase or snake_case; they map to HEEx/HTML:
className→class,phxClick→phx-click,dataTestId→data-test-id
- Each registered HTML element’s allowed attributes and basic value kinds come from:
phoenix.types.HXXTypes(e.g.,InputAttributes,DivAttributes)phoenix.types.HXXComponentRegistry(which tags are registered + which attributes are allowed)
What is checked (TSX-like behavior)
- If the tag is a registered HTML element (e.g.,
div,input,button,a), the linter validates:- attribute names (including camelCase/snake_case/kebab-case normalization)
- wildcard-safe attributes (
data-*,aria-*,phx-value-*) and HEEx directives (:if,:for,:let) - a small set of obvious attribute value kinds (e.g., bool-ish attrs,
phx-hook,phx-click), when statically known
- If the tag is not registered (e.g., a custom Web Component like
<my-widget>), attribute validation is intentionally skipped by default to avoid false positives.
This means:
<div hreff="...">is a compile-time error (typo on a registered element).<my-widget hreff="...">is not an error by default (unknown/custom tags are treated as “user-defined”).
- Phoenix components
<.button ...>are preserved; attributes can be validated via your registry and typedefs. Slots follow registered shapes.
By default, the compiler skips validation for dot-components it cannot resolve unambiguously (to avoid false positives).
If you want TSX-level strictness for component tags, enable globally with -D hxx_strict_components or locally with @:hxx_strict_components (on the class or the render/1 function):
-D hxx_strict_componentsIn strict mode:
- Unknown dot-components (e.g.
<.typo>) are compile errors. - Ambiguous components (multiple
@:componentfunctions with the same name) are compile errors.
Phoenix core tags like <.link>, <.form>, <.inputs_for>, and <.live_component> remain allowed even without a Haxe definition.
By default, :let is allowed even if the component/slot does not declare what type is being bound (in that case the linter cannot type-check field access on the bound variable).
If you want TSX-level strictness for :let, enable globally with -D hxx_strict_slots or locally with @:hxx_strict_slots:
-D hxx_strict_slotsIn strict mode:
- Using
:letrequires a typed let binding (e.g.Slot<EntryType, LetType>or componentinner_block: Slot<..., LetType>). :letmust be a simple variable binding (:let={row}); binding patterns like:let={{row, idx}}are rejected.
If you want to enforce typed phx-hook usage (to prevent drift with your hook registry), enable globally with -D hxx_strict_phx_hook or locally with @:hxx_strict_phx_hook:
-D hxx_strict_phx_hookIn strict mode:
phx-hook="Name"(literal) is rejected.phx-hook={@hook}(dynamic) is rejected.- Use a compile-time constant from a
@:phxHookNamesregistry (recommended:phx-hook=${HookName.Name}in HXX).
Note:
- Phoenix requires a stable DOM id for hooks; the compiler errors if a non-component tag uses
phx-hookwithout anidattribute.
If you want to enforce typed LiveView event names (phx-click, phx-submit, phx-change, ...), enable globally with -D hxx_strict_phx_events or locally with @:hxx_strict_phx_events:
-D hxx_strict_phx_eventsIn strict mode:
- Event names must be compile-time constants, and must be known to the compiler.
- In a
@:liveviewmodule, the compiler can derive allowed events from yourhandle_event/3body (literal strings inswitch (event)andif (event == "...")comparisons). - Otherwise, define a
@:phxEventNamesregistry and reference it from templates (recommended:phx-click=${EventName.Save}in HXX).
Rejected in strict mode:
- Fully dynamic event expressions (e.g.
phx-click={@event}) are rejected because they cannot be validated.
If you want stricter TSX-like checks for known string-literal vocabularies, enable globally with -D hxx_strict_attr_values or locally with @:hxx_strict_attr_values:
-D hxx_strict_attr_valuesIn strict mode, constant string literals are validated for selected attributes:
input[type](e.g.text,email,number, ...)button[type](button|submit|reset)form[method](get|post)textarea[wrap](hard|soft)phx-update(replace|stream|append|prepend|ignore)
Notes:
- This check is intentionally conservative: only compile-time constant string literals are validated to avoid noisy false positives.
- Dynamic values (e.g. interpolated assigns) are not rejected by this rule.
- Typed-first (recommended): use normal Haxe control flow inside
${ ... }and typed helpers likeHeexTemplate.for_each/2. - TSX mode also supports explicit control tags with typed headers:
<if ${cond}> ... <else> ... </else> </if><for ${item in items}> ... </for>
- TSX mode also supports
:fordirective sugar on elements:<li :for ${item in items}>...</li>(lowered to a HEExforblock around the element, with typed binder scope).
- Template strings (migration): block conditionals/loops can use HXX control tags (normalized to HEEx):
<if {cond}> ... <else> ... </if>→ HEExif/elseblock<for {pattern in expr}> ... </for>→ HEExforblock
Note: The {cond} / {pattern in expr} headers inside template strings are not parsed as Haxe AST, so they are not type-checked by the Haxe typer.
- HEEx escaping rules apply; no alternative raw API is introduced. The compiler warns on unsafe patterns in
~H(validator transforms).
- Element/attribute typing:
HXXTypesdefines allowed attributes and kinds. The linter validates attributes for registered HTML tags, producing helpful errors and suggestions (unknown/custom tags are skipped by default). - Assigns typing: The linter cross‑checks
@fieldreferences in~Hagainst the Haxetypedefused forassigns. - Component prop kind typing: component assigns types are reduced to simple “kinds” (e.g.
string,bool,map) and checked against attribute values when resolvable.haxe.extern.EitherType<A, B>(andhaxe.ds.Either<A, B>) is treated as a union kind (kindA|kindB) when both sides are known. - Struct-like externs: if an extern type represents an Elixir struct and should behave like a map in HXX kind checks, annotate it with
@:elixirStruct(e.g.phoenix.JS). - Attribute expressions (in progress): The compiler is moving to a structural AST (
EFragment) so attribute values are typed expressions instead of text.
You have two “vocabularies” you can extend:
- HTML element typing
- Source of truth:
std/phoenix/types/HXXTypes.hx(attribute typedefs likeInputAttributes,DivAttributes, etc.)std/phoenix/types/HXXComponentRegistry.hx(which tags are registered + which attributes are allowed)
- Add a new element or attribute:
- Add/extend the attribute typedef in
HXXTypes.hx - Register the element (or extend its allowed attribute set) in
HXXComponentRegistry.hx - Add a negative snapshot (typo attribute should fail) under
test/snapshot/negative/and runnpm test
- Add/extend the attribute typedef in
- Phoenix component typing (
<.my_component ...>, slots,:let)
- For user components, define a discoverable
@:componentfunction and typed assigns/slots. - The linter uses RepoDiscovery to resolve components and then enforces:
- allowed props / required props
- allowed slots / required slots
- typed
:letwhen the component/slot declares aSlot<EntryProps, LetType>
- Custom HTML tags (Web Components)
- By default, unknown/custom tags skip attribute validation to avoid false positives.
- If you want to opt in to typing for a custom tag, register it in your app using metadata:
@:hxxHtmlTags
class CustomTags {
// Register the tag name (used by `-D hxx_strict_html` / `@:hxx_strict_html`)
@:hxxTagAttrs(["enabled", "variant"])
public static final MyWidget = "my-widget";
}You can generate a small JSON vocabulary for editor tooling (autocomplete/lint helpers) that includes:
- built-in HTML tags + allowed attributes
- discovered dot-components + their props/slots
- global hook/event registries (
@:phxHookNames/@:phxEventNames) - per-
@:liveview:derivedEvents(fromhandle_event/3)templateEvents/templateHooks/templateComponents/templateSlots(from template scanning)usedComponents(best-effort join oftemplateComponents→ component definitions)
Generate:
npm run docs:hxx:indexThis writes tmp/hxx-registry.json.
Notes:
- Registered custom tags are recognized by
-D hxx_strict_html/@:hxx_strict_html(so they don’t error as “unknown tags”). - Attribute validation for custom tags is only enabled when you provide an explicit
@:hxxTagAttrs(...)allowlist.
- Unknown dot-components (
<.typo ...>) are only errors under-D hxx_strict_components/@:hxx_strict_components(recommended when you want TSX-level strictness). - Unknown HTML/custom tags (
<my-widget ...>) are not errors by default; they are treated as user-defined to support custom elements. Registered HTML tags still get strict attribute checking. - If you want TSX-like strictness for custom elements too, enable
-D hxx_strict_html/@:hxx_strict_html:- Unknown HTML/custom tags become errors unless registered or explicitly allowed via
-D hxx_strict_html_allow_tags=my-widget,another-tag.
- Unknown HTML/custom tags become errors unless registered or explicitly allowed via
- Immediate compile‑time feedback with precise messages (field names, expected kinds).
- Idiomatic Elixir output with standard HEEx; easy to review and debug.
- No vendor‑specific runtime helpers; everything compiles to plain Phoenix patterns.
| Aspect | HXX (Haxe→HEEx) | Coconut UI (Haxe) | TSX/JSX (TypeScript/React) |
|---|---|---|---|
| Primary Target | Phoenix HEEx (server) | Haxe UI Virtual DOM/React‑like (client) | React Virtual DOM (client) |
| Output | ~H (string) → Phoenix engine |
Haxe code & VDOM structures | JavaScript/JSX → React elements |
| Typing of HTML attrs | Yes (HXXTypes per element) | Yes (component/property typing) | Yes (DOM/React types) |
| Assigns/Props typing | Yes (render(assigns: T), linter over @field) |
Yes (component props) | Yes (component props) |
| Attribute expressions | {...} (structural AST planned) |
Haxe expressions in HXX/tpl | {...} (JS expressions) |
| Components/slots | Phoenix components <.comp>; slot shapes |
Component system with templates/slots | Components with children/props |
| Control flow | HEEx block if, comprehensions via idioms |
HXX‐templating & Haxe control | JS expressions (ternary/map) |
| Runtime | Server‑rendered HEEx (LiveView) | Client runtime (VDOM/reactive) | Client runtime (VDOM) |
| Escape semantics | HEEx (server‑safe by default) | UI lib‑defined | React escaping rules |
| Integration | Phoenix/LiveView/Ecto idioms | Haxe app frameworks | React ecosystems |
Notes:
- Coconut UI’s HXX (tink_hxx) targets client‑side component rendering with a reactive runtime; Reflaxe.Elixir’s HXX targets server‑side HEEx generation for Phoenix. Both bring strong typing and template ergonomics, but the runtime and integration targets differ.
- TSX offers excellent developer tooling for client apps. HXX focuses on typed server templates that integrate deeply with Phoenix patterns, while allowing shared business logic via Haxe.
- From HEEx: keep your template semantics identical; replace
~Hchunks withhxx('...'). Use Haxe types for assigns. - From Coconut UI/TSX: move client‑side interactivity to LiveView patterns (events, assigns) or to genes‑generated JS when needed. Keep shared validation in Haxe to use on both server and client.
- Attribute‑level AST (EFragment) for typed attribute values and children.
- Linter over attribute AST (beyond literal comparisons).
- Retire string rewrite helpers in favor of pure AST.
- Broaden Phoenix component/slot typing coverage.
All roadmap items are tracked in the Shrimp task “Transform: HXX Assigns Type Linter + Snapshots”.
This is a forward‑looking concept for a Haxe‑augmented UI framework that compiles the same component source to both HEEx (server, Phoenix LiveView) and JSX/ES6 (client, React or similar). The purpose is to share types, business logic, and even component structure across server and client while preserving idiomatic patterns on each side.
Goals
- One component definition, two idiomatic outputs: HEEx for LiveView, JSX for client frameworks.
- Strong typing end‑to‑end: props/assigns, events/messages, slots/children.
- No runtime bridge layer in production builds; all branching happens at compile time.
- Preserve Phoenix/React conventions — never invent fake APIs.
Core Ideas
- Universal component class with typed props and optional slots.
- Target‑specific renderers using metadata annotations.
- Shared logic (validation, derived state) in plain Haxe.
- Compile‑time selection of output (Elixir or JavaScript) with separate build commands.
Sketch (illustrative; not an API commitment):
@:universal
class Badge {
public var label:String;
public var kind:BadgeKind; // Info | Success | Warning
// Shared logic
inline function color():String return switch kind { case Info: "blue"; case Success: "green"; case Warning: "orange"; }
// Server: HEEx (Phoenix LiveView)
@:target("elixir")
public function renderHeex():String {
return hxx('<span class="badge ${color()}">${label}</span>');
}
// Client: JSX (ES6 via genes)
@:target("javascript")
public function renderJsx():String {
return JSX.jsx('<span className={"badge " + color()}>{label}</span>');
}
}Build/Integration
- Server build: HXX →
~Hvia Elixir backend; LiveView owns DOM updates. - Client build: genes → ES6 modules with JSX (or JSX‑like) output for bundlers (esbuild/vite).
- Projects choose per‑component where to render (server, client, or both for progressive enhancement).
Events & Messages (shape‑based)
- Server:
phx-*directives map to typed event enums; handlers are LiveView callbacks. - Client: DOM/React events map to typed callbacks; shapes mirror server enums to share type safety.
- Messages are defined as Haxe enums/typedefs so both sides agree on payloads.
SSR/CSR Strategies
- Server‑only: render via HEEx; no client artifact.
- Client‑only: render via JSX with client runtime.
- Progressive enhancement: HEEx renders baseline; optional client module enhances behavior.
Constraints & Non‑Goals
- Do not invent Phoenix or React APIs — compile to their established patterns.
- Prefer a lowest‑common‑denominator attribute/slot model; target‑specific extras live behind
@:targetsections. - Maintain the No‑Dynamic policy for public surfaces; use precise types for props/events.
Why this is interesting
- Eliminates server/client drift by sharing types and logic.
- Makes LiveView apps incrementally “client‑capable” without a rewrite.
- Gives React teams a typed on‑ramp to Phoenix while retaining familiar patterns.
Open Questions
- Hydration semantics when mixing LiveView and client islands.
- Debuggability and source maps across dual targets.
- Tooling for slot typing parity between components on both sides.
Status: Design exploration. The current compiler already supports Elixir (~H) and JS (via genes) separately; a universal HXX layer would formalize a single authoring model that targets both cleanly.