Type-Safe CSS: Why We Built ClasshSS
Tailwind CSS solved a real problem. Utility-first styling means you stop naming things and start describing them. But Tailwind has a dirty secret: it's all strings. And strings lie.
<div class="bg-blue-500 hover:bg-blue-70 text-whte font-bold py-2 px-4 rounded">
Click me
</div>
Two typos. bg-blue-70 (missing the trailing zero) and text-whte. No build error. No runtime error. Just a button with no hover effect and invisible text. You find out when a user complains, or when you squint at your screen wondering why that div looks wrong.
We spent years building a full-stack Haskell application with Reflex-FRP, beam, and Obelisk. Type safety was a first-class concern everywhere: our routes are type-safe, our database schema is type-safe, our API requests carry their response type in the GADT index. Then we'd write elClass "div" "bg-purple-500 text-pruple-300" and throw all of that away.
ClasshSS is our answer. It's a Haskell library that compiles Tailwind class strings at build time using Template Haskell, with full type checking on every property, color, size, breakpoint, and transition.
The Bug That Broke Us
Last week we ran a contrast audit on our application. We found eight places where text was nearly or completely invisible. The worst one: an onboarding screen where the "thanks" message was rendered in #5E5086 (our primary purple) on a #5E5086 background. A 1:1 contrast ratio. The text was literally the same color as its container.
How did this happen? Two developers, two files, one stringly-typed color constant. The background was set in a box style, the text color in a text style, and nothing in the build caught that they were identical. The type system had no opinion about it because to GHC, both were just Text.
This is not a typo problem. This is a "strings can't express relationships" problem.
What ClasshSS Does
ClasshSS replaces Tailwind class strings with typed Haskell expressions that compile to class strings via Template Haskell.
Before:
elClass "div" "bg-blue-500 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg"
After:
elClass "div" $(classh'
[ bgColor .~^ [ ("def", noTransition (solidColor (Blue C500)))
, ("hover", solidColor (Blue C700) `withTransition` Duration_300)]
, py .~~ TWSize 2
, px .~~ TWSize 4
, br .~~ R_Lg
])
At compile time, the classh splice folds those mutations over a default config, validates the result, and splices a Text literal into your code. No runtime cost. No class generation at page load. Just a string baked into the binary.
If you write bgColor .~~ Blue C500 (forgetting the solidColor wrapper), it won't compile. If you write text_color .~~ White (forgetting the color wrapper for ColorWithOpacity), it won't compile. If you write br .~~ R_Gg (typo), it won't compile because R_Gg doesn't exist.
The Design
ClasshSS is built around three independent config types:
- BoxConfig: layout, spacing, colors, borders, shadows, transforms, cursor
- TextConfigTW: font, size, weight, text color, decoration
- TextPosition: alignment, tracking, line height, overflow, wrapping
These are separate types, not one big bag. You can't accidentally mix box and text properties in the same classh call. Putting bgColor (a BoxConfig field) and text_size (a TextConfigTW field) in the same list is a type error. The compiler enforces separation.
Four Operators
ClasshSS provides four operators for setting properties:
-
The
(.~~)operator sets a constant value:bgColor .~~ solidColor (Blue C500) -
The
(.|~)operator sets responsive breakpoints as a list:text_size .|~ [SM, Base, LG, XL] -
The
(.~^)operator sets states with transitions:bgColor .~^ [("def", noTransition ...), ("hover", ...)] -
The
(.~)operator is the escape hatch for raw text:custom .~ "overflow-hidden"
The first three are type-safe. The last one is a raw Text setter for properties ClasshSS doesn't yet model (position, z-index, overflow, display). Every use of custom is a code smell, and we treat it that way.
Colors Are Types
Tailwind colors are constructors:
data Color
= Black | White | Transparent | Inherit | Current
| Slate ColorNum | Gray ColorNum | Red ColorNum
| Blue ColorNum | Purple ColorNum | ...
data ColorNum = C50 | C100 | C200 | C300 | C400 | C500
| C600 | C700 | C800 | C900 | C950
Blue C500 is a value of type Color. You can't write Blue C550 because C550 doesn't exist. You can't write Bleu C500 because Bleu doesn't exist. The compiler catches it.
Colors are wrapped in ColorWithOpacity for text, and GradientColor for backgrounds (which also supports linear gradients):
-- Solid background
bgColor .~~ solidColor (Blue C500)
-- With opacity
bgColor .~~ solidColorOpacity (Blue C500) 50
-- Gradient
bgColor .~~ linearGradient To_R (Blue C500) (Purple C500)
-- Text color (needs ColorWithOpacity wrapper)
text_color .~~ color White
text_color .~~ withOpacity (Blue C500) 80
Transitions Live With Values
In Tailwind, transitions are disconnected from the properties they animate. You set the hover value in one place and the transition config in another. Forget the transition-colors class and nothing animates. Add duration-300 without transition and it does nothing.
ClasshSS co-locates the transition with the value it applies to:
bgColor .~^ [ ("def", noTransition (solidColor (Blue C500)))
, ("hover", solidColor (Blue C700) `withTransition` Duration_300)
]
The transition config (duration, timing, delay) is attached to the hover value. The library generates the right CSS: the value class for the hover state plus an arbitrary-value transition class. You can't have an animated value without its transition, or a transition without its value.
Responsive Is a List
Tailwind scatters responsive variants across a class string: text-sm sm:text-base md:text-lg lg:text-xl. Four breakpoints, scattered across one long string.
ClasshSS co-locates them:
text_size .|~ [SM, Base, LG, XL]
Position 0 is mobile (no prefix), position 1 is sm:, position 2 is md:, and so on. Provide fewer than 6 values and the remaining breakpoints are unset. The mapping is mechanical and the output is identical, but the code is easier to read and modify.
Compile-Time Validation
The classh Template Haskell splice doesn't just render. It validates. The CompileStyle typeclass returns Either Text Text, where Left is a compile error:
classh' :: (Default s, CompileStyle s) => [(s -> s)] -> Q Exp
classh' muts = case compileS (foldl (\acc f -> f acc) def muts) of
Left e -> fail (T.unpack e) -- GHC compile error
Right s -> lift s -- splice the Text literal
Currently this catches duplicate conditions (setting hover: twice on the same property) and conflicting shorthands (px and pl both writing to the left-padding field). The architecture supports adding more checks over time without changing the user-facing API.
What We Can't Check (Yet)
ClasshSS catches a lot, but it doesn't catch everything. It can't catch:
-
Cross-element contrast. A parent's
bgColorand a child'stext_colorlive in separateclasshcalls. The compiler sees them as independent Text values. The 1:1 contrast bug we found required a manual audit, not a type error. -
Semantic conflicts. Setting
w .~~ TWSize_FullandmaxW .~~ MaxW_Mdis allowed because both are valid individually. Whether they conflict depends on context. -
The custom escape hatch. Anything in
custom .~ "..."is an unchecked string. It can duplicate or conflict with type-safe properties silently.
Cross-element contrast checking is the most interesting open problem. You'd need a system where a parent's background color is threaded through to its children's text color constraints, which requires something closer to a full CSS type system. We haven't built that. For now, we define WCAG-validated color pairs as named constants (typifyTextOnPrimaryCwo is guaranteed 7.2:1 contrast on typifyPrimary) and use discipline.
The Escape Hatch
Some CSS properties don't have type-safe equivalents in ClasshSS yet: position, z-index, overflow, display, gap, whitespace. For these, there's custom:
$(classh' [ bgColor .~~ solidColor (Blue C500)
, p .~~ TWSize 4
, custom .~ "absolute top-0 right-0 z-10 overflow-hidden"
])
Every use of custom is tracked. Before using it, we search the ClasshSS source to confirm there's genuinely no typed alternative. If we find ourselves using custom for something frequently, we add a type for it. The goal is zero custom usage for supported properties.
When you truly need a runtime class string (colors from user input, dynamic styling), there's classhUnsafe, which uses the same fold-over-mutations API but evaluates at runtime with no compile-time validation. You get the type structure without the compile-time guarantee.
Grid, Not Flex
ClasshSS doesn't support flexbox. This is deliberate.
Flexbox is powerful but unpredictable. Items shrink in non-obvious ways. justify-content and align-items swap axes depending on flex-direction. Nested flex containers interact in ways that are hard to reason about. Every flex layout is a small puzzle.
CSS Grid is declarative: you say how many columns you want, and each child says how many columns it spans. No ambiguity, no implicit sizing, no axis confusion.
ClasshSS provides grid primitives through a companion library called reflex-classh:
gridCol Col12 $ do
col [12, 12, 6, 4] $ text "Full on mobile, half on md, third on lg"
col [12, 12, 6, 8] $ text "Complementary column"
For centering, instead of flex items-center justify-center:
centerSimple $ text "Centered both axes"
-- or
$(classh' [pos .~~ centered])
This is opinionated. Not everyone will agree. But after two years of grid-only layout, we haven't missed flexbox.
Is It Worth It?
ClasshSS adds complexity. Template Haskell increases compile times. The operator syntax has a learning curve. solidColor (Blue C500) is more verbose than bg-blue-500.
But it removes an entire category of bugs. We no longer debug invisible text, broken hover effects, or mystery spacing from typos in class strings. We no longer grep for bg-blue- to find which shade we used. We refactor colors by changing a type definition, and the compiler shows us every affected callsite.
The contrast audit that motivated this article found eight bugs. All of them were invisible-text or invisible-border issues caused by color values that were too close to their backgrounds. Every one of those bugs survived code review, QA, and months of production use. A type system that could express "this text color must contrast with its background" would have caught them at compile time.
We're not there yet. But we're closer than "bg-purple-500 text-purple-300".
ClasshSS is open source. You can find it and its companion libraries (reflex-classh, templates) on GitHub.