DEV Community

loading...

Make your CSS safer by type checking Tailwind CSS classes

virtualkirill profile image Kirill Vasiltsov Originally published at kirillvasiltsov.com ・5 min read

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;
Enter fullscreen mode Exit fullscreen mode

A small recap of Template Literal Types

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

  • For quite some time now, it's been possible to use literal types in TypeScript
type MagicNumber = 42;
type MagicString = "magic";
Enter fullscreen mode Exit fullscreen mode
  • In JavaScript you can embed literals in template strings:
const name = "John";
const age = 42;
const person = `${name}: ${age}`;
// "John: 42"
Enter fullscreen mode Exit fullscreen mode
  • Since 4.1, you can embed literal type definitions inside special literal string type definitions which are called Template Literal Types:
type Name = "John";
type Age = 42;
type Person = `${Name}: ${Age}`;
// type Person = "John: 42"
Enter fullscreen mode Exit fullscreen mode

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}`;
// ???
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.

  • First of all let's outline the new type. For now it's a simple conditional type:
type Tailwind<S> = S extends ValidClass ? S : never;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
  • Next, let's see how the type would be used in e.g. a function:
function doSomethingWithTwClass<S>(cls: Tailwind<S>) {
  return cls;
}
Enter fullscreen mode Exit fullscreen mode

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

image.png

  • Next, let's allow space. This is surprisingly the hardest part and feels like magic. As you may know, it is possible to use the infer keyword in TypeScript conditional types. This allows us to use the inferred type inside true expressions. This applies to template literal types as well. We can do something like this:
type Tailwind<S> = S extends `${infer Class} ${infer Rest}` ? ...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide