Engineering · Mar 11, 2026

Type-Safe HTML in TypeScript: Wrong Markup Won't Compile

Three typos got past TypeScript, ESLint and code review, so we built fluent-html to turn mistakes like them into compile errors.

T

By Toni · 10 min read

← All posts
Type-Safe HTML in TypeScript: Wrong Markup Won't Compile

It was a Wednesday when we found the bug. A route handler listened on /users/:userId, and somewhere deep in a view file a template literal built `/usrs/${userId}`. TypeScript didn't blink, and neither did ESLint. We had no type-safe HTML in TypeScript back then, so to both tools that URL was just a string.

The tests passed as well, because the test used the same misspelled path. For three days users hit a 404 that no one noticed, since the page just said "Not found" and they assumed the user had been deleted. We found it by accident while working on something else.

A week later came a different bug, and it also compiled and shipped. We use HTMX, a small library that lets a button fetch HTML from the server and swap it into the page. One button's response was aimed at hx-target="#user-lst", and the element's real id was user-list.

A user clicked "Load More" and nothing happened. There was no error and no crash, and we heard about it from a support ticket the following week.

Then came the form bug. A registration page had <input type="emial">, with two letters swapped. The browser didn't complain and quietly fell back to type="text", which meant no email validation and no email keyboard on phones. Users submitted garbage data for two weeks before someone noticed that signup quality had dropped, and we traced it by diffing form analytics.

All three bugs compiled, passed review and broke in production. TypeScript, the language we chose specifically for safety, couldn't catch any of them, because each mistake sat inside a string and the compiler doesn't look inside strings.

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

Type-Safe HTML in TypeScript with fluent-html

If TypeScript wasn't going to catch these bugs for us, we would build a layer that did. The result is fluent-html, a TypeScript HTML builder with zero dependencies, made for server-side rendering (the server builds each page as finished HTML and sends it to the browser). Its API is fluent, which means every method returns the element, so calls chain one after another. Most of the work went into the type system under that API, whose job is to make wrong code fail to compile.

The examples use fluent-html 5.9, the version in our repository when this post went up in March 2026. Later major versions renamed the styling methods, and fluent-html.com documents the current API.

Chainable Fluent Methods

Every HTML element is a function, and every attribute is a typed method with autocomplete. An attribute with a fixed set of values takes a string literal union (a type that lists every allowed value), so Input().setType("emial"), the form bug above, is a compile error. Tailwind CSS, which styles a page with small single-purpose classes like px-4, gets one method per utility. Tailwind values autocomplete, and in 5.9 they also accept any other string, so a custom theme color works and a misspelled one gets through.

// 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

HTMX options are typed as well. In our apps a swap's target comes from defineIds(), which declares each element id once, so the button and the element share one value and a misspelled ids.userLst is a compile error. HxSwap, HxTrigger and HxSync are template literal types (types that describe the shape of a string), so the editor offers every valid mix of a base value with scroll, timing or transition modifiers as you type. Like the Tailwind values, in 5.9 they also accept any other string, so a misspelled swap still compiles.

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

fluent-html escapes every string it renders, so text a user typed cannot turn into markup on the page, which is the attack known as cross-site scripting (XSS). Raw HTML goes in only through an explicit Raw() call.

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() runs reusable style functions on an element, and .when() runs them only while a condition is true. You write a modifier once and use it anywhere.

// 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 loops over an array, a range or a count through one overloaded function. Match handles a discriminated union (a type whose variants share a tag field such as status), and each branch sees only the fields of its own variant. TypeScript calls that narrowing, and IfThen does it for values that may be missing, so its callback receives a value the compiler knows is there.

// 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
)

Making Impossible States Unrepresentable

Take a page that loads data. The tempting model is three optional fields, loading, error and data, which allow eight combinations when only three make sense. The other five are bugs the type system permits, and sooner or later the code will produce one.

The fix is a discriminated union, and every TypeScript team should use one for state like this. It models the page as three variants, and a value can only ever be one of them.

// ✗ 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 hard part is rendering them, because switch statements and if chains in a view don't guarantee that every case is handled. Add a variant next month and nothing tells you which views need updating, so the bug waits until a user reaches the unhandled branch.

Match closes that gap. You pass the tag field as the second argument ("status" here), and the compiler then 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())

Reading s.data in the error branch is a compile error. Add a variant like { status: "maintenance"; until: Date } and the compiler points to every Match without a default that now needs a new branch. No catch-all quietly swallows the new case. State bugs show up in the view layer, so that is where they should be caught.

IfThen does the same job for values that may be missing. It accepts T | null | undefined and calls back with a plain 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

With IfThen you stop writing user.avatar!, the non-null assertion that tells the compiler to trust you. The compiler has already proven the value is there.

How Type-Safe Routes Catch a Misspelled URL

Back to the first bug, with /users/:userId in the handler and /usrs/${userId} in the template. One missing letter in the path cost three days of silent 404s.

The cause is that URLs are strings, and a string has no structure the compiler can check. TypeScript has no way to know that :userId in a path pattern belongs to a userId value somewhere else, until the path itself becomes a type.

defineRoutes makes the path a type. Each route's path turns into a typed contract, and every URL built from it is checked against that contract.

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

That removes the opening bug for good. Views build URLs with routes.userDetail.resolve(...) and never type the path themselves, so there is no /usrs/ to misspell, and a wrong parameter name is underlined in the editor before you save.

Under the hood, a conditional type reads the path string at compile time. It calls itself on the rest of the path until it has collected every :param segment into a union of names.

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"

Tailwind Variants as Scoped Callbacks

Tailwind variants are string prefixes such as hover:bg-blue-600, md:text-lg and dark:hover:bg-gray-800, and each one limits a class to a state, a screen width or dark mode. In practice, class strings end up looking 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>

Nothing in that string autocompletes or gets checked. Misspell a variant and the class silently doesn't apply. Conditional classes turn into nested ternaries or clsx() arrays, and every responsive or interactive variant adds one more place to make a mistake.

.on() takes a state such as "hover", and .at() takes a breakpoint (a minimum screen width) such as "md". Each one scopes a callback to its variant.

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 the same element, with every Tailwind method and its autocomplete. State and breakpoint names have no string fallback, so .on("hovr", ...) fails to compile. Inside the callback every method call gets the variant prefix automatically, and t.background("blue-600") comes out as hover:bg-blue-600.

What the Compiler Can't Catch

The type system catches route typos, impossible states and invalid attribute values. It checks one method call at a time, though, and cannot see how two calls on the same chain relate to each other. Some code is wrong in meaning and still perfectly valid TypeScript, 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.

We found all three kinds in our own code after we adopted the fluent API, so we built an ESLint plugin for them. It has 16 rules, and 8 of them can fix the code automatically.

Calling .setClass() after fluent methods is flagged, including inside .when() and .apply() callbacks. Inside a .setClass() string the plugin knows more than 25 groups of utilities that cancel each other out, and it rewrites more than 70 Tailwind class patterns into fluent method calls. It also flags .addAttribute() where a typed setter exists, children wrapped in an array where they should be separate arguments, and raw id strings that should come from defineIds(). Two fluent calls that conflict, like .flex().grid() above, still get through, because the conflict rule only reads class 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 rejects code with the wrong shape before it runs, and the linter catches mistakes that are still valid TypeScript. What remains for the view layer is a much smaller set of bugs, and those are a job for tests. We describe the same habit across our whole stack in how we make mistakes fail the build.

Why Server-Rendered HTML Is Easy to Test

Server-side rendering also makes testing simpler. When the server returns finished HTML, one test can cover the whole server side. It sends an HTTP request that hits the database and runs the business logic, then asserts on the HTML that comes back. No browser, DOM or component mounting is involved, only a request in and markup out.

A client-side framework needs more for the same check. You mock the API, mount the component tree and simulate a browser, and the test still covers only a slice of the path from request to rendered page. With server-side rendering, the same kind of test covers the full server path and runs in milliseconds. When that HTML is built with typed methods, the compiler has already rejected the typos from the start of this post before a single test runs, which leaves the tests free to check what the code does.

Our fluent-html page shows where the library sits in the apps we build today, and how we ship describes the workflow around it. The source is on GitHub: fluent-html, the ESLint plugin and the Tailwind extractor, which tells Tailwind which classes the fluent methods produce.

Stay in the loop

Don't miss the next one.

One email when we publish something worth your time.

No spam. Unsubscribe in one click.