Engineering

What If Wrong HTML Couldn't Compile?

How we used TypeScript's type system to eliminate an entire class of HTML bugs

Not just malformed tags — wrong attributes, invalid values, misspelled route params, orphaned HTMX targets, impossible view states. We built a type system that rejects all of them before the code runs.

T

Toni

Co-founder · Mar 11, 2026 · 10 min read

Share

It was a Wednesday when we found the bug. A route handler: /users/:userId. Somewhere deep in a view file, a template literal: `/usrs/${userId}`. One character off. TypeScript didn't blink. ESLint didn't blink. The tests passed — because the test used the same misspelled path. It compiled, it shipped, and for three days users hit a 404 that no one noticed because the page just showed "Not found" and they assumed the user had been deleted.

We found it by accident while working on something else.

A week later, a different bug. An HTMX target: hx-target="#user-lst". The actual element ID was user-list. Same story. Compiled fine. Shipped fine. A user clicked "Load More" and nothing happened. No error. No crash. Just silence. We found out from a support ticket the following week.

Then the form bug. A registration page with <input type="emial">. One character off. The browser didn't complain — it just silently fell back to type="text". No email validation. No mobile keyboard. Users submitted garbage data for two weeks before someone noticed the signup quality had dropped. We found it by diffing form analytics.

Three bugs. All compiled. All passed review. All broke in production. And TypeScript — the language we chose specifically for safety — couldn't catch any of them. They're string-shaped problems in a structurally-typed world.

We got tired of finding these bugs the hard way. So we asked a different question: what if wrong HTML couldn't compile?

The Tool We Built to Fix It

We decided that if TypeScript wasn't going to catch these bugs for us, we'd build a layer that would. The result is fluent-html — a zero-dependency TypeScript HTML builder with a fluent API, built for server-side rendering.

The fluent API is the delivery mechanism. The real investment is in the type system underneath — making wrong code unrepresentable so that entire categories of bugs simply can't exist in our codebase.

Here's what it gives you.

Chainable Fluent Methods

Every HTML element, attribute, and Tailwind utility is a typed method with full autocomplete. No class strings. No template literals. Just typed method calls that the compiler validates:

// Every element, attribute, and style — typed and autocompleted
Button("Save")
  .setType("submit")              // ← string literal union
  .padding("x", "4").padding("y", "2")
  .background("blue-500").textColor("white").rounded("lg")
  .transition("colors")
  .on("hover", t => t.background("blue-600").scale("105"))
  .on("focus", t => t.ring("2").ringColor("blue-300"))
  .at("md", t => t.padding("x", "8").textSize("lg"))

Form().setMethod("get")           // ✓ "get" | "post" | "dialog"
A("Docs").setTarget("_blank")     // ✓ "_self" | "_blank" | "_parent" | "_top"
Input().setAutocomplete("email")  // ✓ autocomplete hint union

Native HTMX Integration

Every HTMX attribute is typed — and not as flat enums. Types like HxSwap, HxTrigger, and HxSync are grammars built from template literal types that compose base values with modifiers, timing, and scroll behaviors into infinite valid combinations. Here's HxSwap as an example:

Button("Load Users").setHtmx(hx("/api/users", {
  target: ids.userList,
  trigger: "click",
  swap: "outerHTML",                                  // base strategy
}))

// HxSwap composes base strategies with modifiers:
swap: "outerHTML"                                     // base
swap: "outerHTML scroll:top"                          // + scroll modifier
swap: "outerHTML scroll:bottom settle:200ms"          // + two modifiers
swap: "innerHTML transition:true"                     // + transition
swap: "innerHTML swap:500ms"                          // + timing
swap: "outerMorph show:top settle:100ms"              // morph + show + settle

XSS Protection Built In

All string content is automatically escaped. There is no way to accidentally inject raw HTML — you have to explicitly opt in with Raw():

Div("<script>alert('xss')</script>")
// → <div>&lt;script&gt;alert('xss')&lt;/script&gt;</div>

Div(Raw("<b>Trusted HTML</b>"))
// → <div><b>Trusted HTML</b></div>

Conditional & Reusable Modifiers

.apply() composes reusable style functions. .when() applies them conditionally — create modifier functions once, use them everywhere:

// Reusable style modifiers
const card = (t: Tag) =>
  t.padding("6").background("white").rounded("lg").shadow("md");

const activeRing = (t: Tag) =>
  t.ring("2").ringColor("blue-400");

const loadingState = (t: Tag) =>
  t.opacity("50").cursor("wait").toggle("disabled");

// Compose with .apply(), conditionally with .when()
Div("Dashboard")
  .apply(card)
  .when(isActive, t => t.apply(activeRing))
  .when(isLoading, t => t.apply(loadingState))

Control Flow with Type Narrowing

ForEach iterates arrays, ranges, or counts — all in a single overloaded API. Match narrows discriminated unions per branch. IfThen narrows nullable values into their non-null type:

// ForEach — arrays, numbers, and ranges
ForEach(users, (user) => Li(user.name))                     // iterate items
ForEach(users, (user, idx) => Li(`${idx}. ${user.name}`))   // with index
ForEach(5, (i) => Star())                                   // repeat 5 times

// Match — exhaustive narrowing on discriminant key
type PageState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: User[] };

Match(state, "status", {
  loading: ()  => Spinner(),
  error:   (s) => Alert(s.message),   // s: { status: "error"; message: string }
  success: (s) => UserList(s.data),   // s: { status: "success"; data: User[] }
})

// IfThen — nullable narrowing
IfThen(user.avatar, (avatar) =>
  Img().setSrc(avatar).rounded("full")  // avatar: string, not string | undefined
)

Each feature alone is useful. Together they form a closed system where every piece reinforces the others. Let's look at how each one eliminates a class of bug.

Making Impossible States Unrepresentable

Here's a pattern every TypeScript team should adopt, and one that Match makes effortless. Consider a page that loads data. The tempting model: three optional fields — loading, error, data. Eight possible combinations. Only three are valid. The other five are bugs your type system permits, so eventually your code will produce them.

The fix: discriminated unions. Model the states as what they actually are — mutually exclusive variants:

// ✗ 8 possible combinations — only 3 are valid
type PageState = {
  loading?: boolean;
  error?: string;
  data?: User[];
};

// ✓ Exactly 3 states — nothing else is representable
type PageState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; data: User[] };

TypeScript has had discriminated unions for years. The problem isn't modeling the state — it's rendering it. You end up writing switch statements or if chains with no exhaustiveness guarantee. Add a new variant next month, and nothing tells you which views need updating. The bug doesn't surface until a user hits the unhandled branch.

Match closes that gap. The discriminant key — "status" — as a second argument enables per-branch type narrowing. The compiler knows exactly which fields exist in each callback:

Match(state, "status", {
  loading: ()  => Spinner(),
  error:   (s) => Alert(s.message),   // s narrowed to { status: "error"; message: string }
  success: (s) => UserList(s.data),   // s narrowed to { status: "success"; data: User[] }
})

// Don't need to handle every case? Provide a default:
Match(state, "status", {
  error: (s) => Alert(s.message),
}, () => Spinner())

Try accessing s.data in the error branch — compile error. Add a new status variant like { status: "maintenance"; until: Date } — the compiler tells you every Match in your codebase that needs a new branch. No default swallowing unknown cases. No runtime surprises.

This is the core insight: the view layer is where state bugs manifest, so the view layer is where they should be caught.

IfThen is the smaller sibling — nullable narrowing for the view layer. The overload accepts T | null | undefined, and the callback receives T:

// ✓ avatar narrowed from string | undefined → string
IfThen(user.avatar, (avatar) =>
  Img().setSrc(avatar).rounded("full")
)

// ✓ IfThenElse for the else branch
IfThenElse(
  user.name,
  (name) => Span(name),
  ()     => Span("Anonymous"),
)

// ✗ TypeScript doesn't narrow user.avatar in the value position
user.avatar ? Img().setSrc(user.avatar) : null
//                         ^^^^^^^^^^^
// Still string | undefined — you end up re-checking or casting

IfThen eliminates ! assertions. Instead of user.avatar! to silence the compiler, the type is proven narrow — not asserted narrow.

Type-Safe Routes — URLs as Typed Contracts

Back to the first opening bug. /users/:userId in the handler, /usrs/${userId} in the template. One character off in the path. Three days of silent 404s.

The root cause isn't carelessness — it's that URLs are strings, and strings don't have structure. TypeScript can't know that :userId in a path pattern should correspond to a userId variable somewhere. Until you make it know.

defineRoutes turns path strings into typed contracts. The path you write becomes the type system's source of truth:

import { defineRoutes } from "fluent-html";

const routes = defineRoutes({
  userList:   { method: "GET",  path: "/users" },
  userDetail: { method: "GET",  path: "/users/:userId" },
  createUser: { method: "POST", path: "/users" },
});

// No params? No argument required.
routes.userList.resolve()                        // → "/users" ✓
routes.createUser.resolve()                      // → "/users" ✓

// Has :userId param? Object with exactly that key is required.
routes.userDetail.resolve({ userId: "123" })     // → "/users/123" ✓

routes.userDetail.resolve({})                    // ✗ property 'userId' is missing
routes.userDetail.resolve({ usrId: "123" })      // ✗ 'usrId' does not exist

The opening bug — gone. Not fixed. Gone. You can't misspell /usrs/ when the route defines /users/:userId. The compiler catches it before you save the file.

Under the hood, a recursive conditional type walks the path string at the type level, extracting every :param segment into a union:

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

// "/users/:userId"                    → "userId"
// "/users/:userId/posts/:postId"      → "userId" | "postId"

The Variant Proxy — Scoped Context as API Design

This section isn't about preventing a specific bug. It's about a pattern we think is genuinely new.

Tailwind variants are string prefixes. hover:bg-blue-600, md:text-lg, dark:hover:bg-gray-800. In practice, this means your class strings look like this:

// ✗ Template literal Tailwind — no validation, no autocomplete
<button class={`
  bg-blue-500 text-white px-4 py-2 rounded
  hover:bg-blue-600 hover:scale-105
  focus:ring-2 focus:ring-blue-300
  md:px-8 md:text-lg
  ${isLoading ? "opacity-50 cursor-not-allowed" : ""}
`}>Save</button>

No autocomplete. No validation. Typo in a variant name? Silent failure — the class just doesn't apply. Conditional classes devolve into ternary soup or clsx() arrays. And every responsive or interactive variant doubles the surface area for mistakes.

.on() and .at() solve this with scoped variant contexts:

Button("Save")
  .background("blue-500").textColor("white")
  .padding("x", "4").padding("y", "2").rounded()
  .transition("colors")
  .on("hover", (t) => t.background("blue-600").scale("105"))
  .on("focus", (t) => t.ring("2").ringColor("blue-300").outline("none"))
  .when(isLoading, (t) => t.opacity("50").cursor("not-allowed").toggle("disabled"))
  .at("md", (t) => t.padding("x", "8").textSize("lg"))

The callback receives this — every Tailwind method available with full autocomplete inside the scope. You can't pass an invalid variant name to .on(). And inside that callback, every method call automatically gets the variant prefix. You write t.background("blue-600") and the output is hover:bg-blue-600.

What the Compiler Can't Catch

We've shown how the type system catches route typos, impossible states, and invalid attribute values. But TypeScript has limits. It can reason about individual method signatures — it can't reason about relationships between method calls on the same chain. Some bugs are semantically wrong but syntactically valid, even in a fully typed fluent API.

// 1. Style overwrite — setClass replaces ALL previously set classes
Button("Save")
  .background("blue-500").textColor("white").rounded("lg")
  .setClass("p-4")
  // ^^^ background, textColor, rounded — all gone. Silently replaced.
  // TypeScript can't know that setClass wipes prior method calls.

// 2. Class conflicts — mutually exclusive utilities, both compile
Div("Content").flex().grid()              // flex AND grid? Only one wins.
Div("Content").padding("4").padding("8")  // Two padding values. Which applies?

// 3. Escape hatch misuse — string classes for things with typed methods
Div("Card").setClass("bg-red-500 flex justify-center items-center")
// Compiles fine. But you've opted out of every safety guarantee.
// Typo in "justify-center"? No compiler error. No autocomplete.

These aren't hypothetical. They're the bugs we found in our own codebase after adopting the fluent API. The type system caught entire classes of bugs — and then we found the ones it couldn't.

So we built an ESLint plugin. 16 rules. 8 auto-fixable. It catches the semantic bugs the compiler can't.

It flags style overwrites — calling .setClass() after fluent methods, even inside .when() and .apply() callbacks. It catches conflicting Tailwind utilities across 25+ conflict groups. It recognizes 70+ Tailwind string patterns in .setClass() and auto-fixes them to fluent method calls. And it enforces API best practices: typed setters over .addAttribute(), variadic children over arrays, defineIds() over raw ID strings.

// Before — ESLint flags this with auto-fix available
Div().setClass("bg-red-500 p-4 flex justify-center items-center")

// After auto-fix — every class is now a typed, autocompleted method call
Div()
  .background("red-500")
  .padding("4")
  .flex()
  .justifyContent("center")
  .alignItems("center")

The compiler is the first wall — it rejects structurally wrong code before it runs. The linter is the second — it catches the semantic mistakes that slip through valid types. Together, the remaining surface area for view-layer bugs is vanishingly small.

Closing Thoughts

There's a deeper advantage to server-side rendering that rarely gets discussed: testability. When your server returns HTML — not JSON for a client framework to render — you can test the entire stack in a single assertion. Send an HTTP request, hit the database, run the business logic, and assert on the actual HTML that comes back. No browser. No DOM. No headless Chrome. No component mounting. Just a request in, markup out.

Try that with a client-side framework. You'd need to mock the API, mount the component tree, simulate a browser environment, and even then you're testing a slice — never the full path from request to rendered output. With SSR, every test is effectively an end-to-end test that runs in milliseconds.

And when that HTML is built with typed methods instead of template strings, the compiler has already rejected an entire class of bugs before the test even runs. Type safety at build time. Full-stack testability at test time. That's the compound return of SSR with a typed view layer.

fluent-html · ESLint Plugin · Tailwind Extractor

#typescript #fluent-html #type-safety #ssr #htmx #functional-programming
T

Toni

Co-founder

View all posts

Don't miss the next one

One email when we publish. Engineering deep-dives, product strategy, and lessons from real client projects — no spam, no fluff.

No spam. Unsubscribe anytime.