Make your CSS safer by type checking Tailwind CSS classes

Tailwind CSS is quickly gaining popularity among developers. I've noticed that it fits well in TypeScript projects. It turns out you can use the freshly released Template Literal Types to type check your CSS!

TL;DR

type Tailwind<S> = S extends `${infer Class} ${infer Rest}`
  ? Class extends ValidClass
    ? `${Class} ${Tailwind<Rest>}`
    : never
  : S extends `${infer Class}`
  ? Class extends ValidClass
    ? S
    : never
  : never;

A small recap of Template Literal Types

First of all let's see what these types are.

type MagicNumber = 42;
type MagicString = "magic";
const name = "John";
const age = 42;
const person = `${name}: ${age}`;
// "John: 42"
type Name = "John";
type Age = 42;
type Person = `${Name}: ${Age}`;
// type Person = "John: 42"

It's OK to embed unions of literal types too. In this case, although the Template Literal Type definition looks like it's one string, what you get is a union of strings, that reflects all possible combinations:

type First = "A" | "B";
type Second = "A" | "B";
type Combined = `${First}${Second}`;
// ???

What does Combined look like? Behind the scenes, TypeScript calculates all possible combinations for you:

table.png

So the resulting type is "AA" | "AB" | "BA" | "BB"!

Simple type checking of utility classes

Tailwind CSS comes with a lot of utility classes, but they are not random and follow a certain pattern. For example, all background color classes start with bg and end with luminance of 100 through 900. The number of default colors at the moment of writing is around 10. For example: bg-red-400, bg-purple-900 etc. So, you can type all possible background colors like this:

type BgColor = 'bg-red-100' | 'bg-red-200' | 'bg-red-300' | ...... 'bg-purple-800' | 'bg-purple-900';

However, this is very unmanageable. If new luminance like 1000 or new color like mauve is added, you will need to add every combination to this huge union. That's exactly where template literal types come to rescue:

type Colors = "red" | "purple" | "blue" | "green";
type Luminance = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type BgColor = `bg-${Colors}-${Luminance}`;
// type BgColor = "bg-red-100" | "bg-red-200" | "bg-red-300" | "bg-red-400" | "bg-red-500" | "bg-red-600" | "bg-red-700" | "bg-red-800" | "bg-red-900" | "bg-purple-100" | "bg-purple-200" | "bg-purple-300" | ... 23 more ... | "bg-green-900"

We can do the same for Space Between classes, only this time also taking into account breakpoint variants.

type Distance = 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
type Breakpoints = "xs:" | "sm:" | "md:" | "lg:" | "xl:" | "";
type Space = `${Breakpoints}space-${"x" | "y"}-${Distance}`;
// type Space = "space-x-0.5" | "space-x-1" | "space-x-1.5" | "space-x-2" | "space-x-2.5" | "space-x-3" | "space-x-3.5" | "space-x-4" | "space-x-5" | "space-x-6" | "space-x-7" | "space-x-8" | ... 155 more ... | "xl:space-y-10"

Type check arbitrary strings that contain Tailwind classes

It's cool that we can type check background colors or margins, but in real world we would be passing long arbitrary strings with classes like bg-purple-900 space-x-2 xl:space-x-4. How can we type check them as well? There is no regularity and order in these strings.

However, we do expect them to be a bunch of valid Tailwind classes that are separated by space. That's enough regularity. We can type check them with Template Literal types too! Let's implement the type step by step.

type Tailwind<S> = S extends ValidClass ? S : never;

Here, ValidClass is a union of all possible Tailwind classes. For the sake of this example, let's imagine that only valid classes are the Space and BgColor that we constructed above. So when we pass some string as a generic S, our type becomes the literal that we passed if it is a valid class, and never if it is not.

type Good = Tailwind<"bg-red-400">;
type Bad = Tailwind<"bg-red-4030">;
// type Good = "bg-red-400"
// type Bad = never;
function doSomethingWithTwClass<S>(cls: Tailwind<S>) {
  return cls;
}

If we pass the wrong class, it's properly detected and highlighted by TypeScript:

image.png

type Tailwind<S> = S extends `${infer Class} ${infer Rest}` ? ...

By doing this we can infer the string before the space and the rest of the string right after the space.

type Tailwind<S> = S extends `${infer Class} ${infer Rest}`
  ? Class extends ValidClass
    ? `${Class} ${Tailwind<Rest>}`
    : never
  : never;

If the inferred string is a valid Tailwind class we concatenate it and the type checking result of the rest of the string, which is done recursively. As soon as there is a wrong class, the type becomes never. There is one thing we forgot to consider though: sometimes the string has only one valid class, and no spaces, so we need to handle that as well:

type Tailwind<S> = S extends `${infer Class} ${infer Rest}`
  ? Class extends ValidClass
    ? `${Class} ${Tailwind<Rest>}`
    : never
  : S extends `${infer Class}`
  ? Class extends ValidClass
    ? S
    : never
  : never;

And that's it! You can see that it works below:

image.png

For those who want to play with it more, here's the TS Playground link.