Advanced TypeScript: The ultimate Tailwind typings

This post is about implementing utilities for the ultimate Tailwind-React ergonomics and typings for it.

In the process, we'll learn about most of the advanced TypeScript features including complex conditionals, template literals, and recursive types. The typings are demonstrated via React components, but the final solution would work in any TypeScript project.

To get an idea what we're building, you can check out the final monstrous typings in a Replit project:

You need to fork the repl if you want to enable TypeScript IntelliSense. Note that the typing hints appear slower in the online environment compared to a local setup.

Quick intro to Tailwind

Tailwind's website has an accurate pitch that describes its purpose well:

A utility-first CSS framework packed with classes like flex, pt-4, text-center and rotate-90 that can be composed to build any design, directly in your markup.

If you're sceptical about Tailwind in general, I suggest reading CSS Utility Classes and "Separation of Concerns" by Adam Wathan. Anyways, Tailwind produces plain CSS, everything is customisable via the config file, and its public API is the class names.

Constraints

There are a few constraints that affect the shape of our solution. The first constraint is about string concatenation. Using Tailwind in dynamic components, such as in React, has one vital rule.

It is important to avoid dynamically creating class strings in your templates with string concatenation, otherwise PurgeCSS won't know to preserve those classes.

In other words, don't do this:

<p className={`text-${color}`}>Hello!</p>

When doing string concatenation, Tailwind doesn't know which specific text- color classes to include in the CSS build. It uses a regex to find complete strings from the source code to detect which Tailwind classes are actually used in the project. Using the information, Tailwind can purge unnecessary CSS.

You can configure a static list of regex class name patterns that Tailwind will leave to the final build, but it easily leads to bloated CSS.

Purging is important, because there are a huge amount of possible class names. It might be a bit surprising, but there are actually tens of thousands of class names in Tailwind by default. Unpurged output.css from Tailwind can weigh even 8MB (unminified, uncompressed).

It sounds scary, but in reality your application will use only a fraction of them. Maybe a few thousands at maximum, which totals to some 10kB.

Tailwind class names Venn diagram

The second constraint is that Tailwind class names have equal specificity. You can't override previously written class names as you might intuitively expect:

<button className="rounded p-4 flex p-0">Ok</button>

In the above example p-0 does not override p-4. Whichever class is defined first in Tailwind's output.css wins. That's how CSS works in general. Another example for a real use case:

// Doesn't work. Background color and hover effect
// can't be changed by appending new classes to the end.
const baseCls = "rounded p-4 bg-primary hover:cursor-pointer"
const disabledCls = "bg-gray-200 hover:cursor-not-allowed"
const finalCls = `
${baseCls}
${props.disabled ? disabledCls : ''}
`;
// Do this instead.
const baseCls = "rounded p-4"
const primaryCls = "bg-primary hover:cursor-pointer"
const disabledCls = "bg-gray-200 hover:cursor-not-allowed"
const finalCls = `
${baseCls}
${props.disabled
? disabledCls
: primaryCls
}
`;

Here's a GitHub issue discussion about this commonly appearing topic.

Now that we know these limitations, the following decisions make more sense.

The desired result

It sounds like a trivial task: simply add Tailwind class names to className prop. Well, it actually is if you are happy with the ergonomics, but we are aiming pretty high here (read: over engineering). Achieving a great developer experience with a clean, readable, and type-safe solution with all the above constraints in mind does require some thinking.

There are multiple ways to dynamically format className strings, but to me classnames provides the perfect balance of readability and convenience. It's great as is, but there's nothing guarding you from typos or using Tailwind class names that were purged for some reason.

What we want is classnames, but with strict typings. Let's call this new more strict function cn.

import classnames from 'classnames'
// TODO: Implement typings
const cn = (...args: Todo) => classnames(...args)

After implementing the types, we should be able to catch all the incorrect calls at type-level. The API of classnames is flexible, let's look at the different possibilities we need to be able to handle.

Regular class name string.

const Hello = () => {
// typo in regular string
return <p className={cn(`text-grau-100 bg-white`)}>
Hello!
</p>
}

An array of class name strings.

type Props = { disabled?: boolean }
const Hello2 = ({ disabled }: Props) => {
return <p className={cn([
'flex my-5',
// typo in a string in the array format
`text-grau-800 bg-white`,
disabled ? 'bg-gray-300 cursor-not-allowed' : ''
])}>
Hello!
</p>
}

An object where each key is a string of class names.

type Props = { dense?: boolean }
const Hello3 = ({ dense = false }: ) => {
return <p className={cn(`text-gray-800 bg-white`, {
// typo in object key format
'px-asd2 py-1 my-2': dense,
// try this:
// 'px-2 py-1 my-2': dense,
'px-3 py-2 my-3': !dense,
})}>
Hello!
</p>
}

A combination of multi-line class name string and object format.

type Props = { dense?: boolean }
const Hello4 = ({ dense = false }: Props) => {
return <p
// long class name definition with a typo.
// note the leading and trailing spaces
// on each line
className={cn(`
flexxx
text-gray-800 bg-white
hover:text-gray-900
hover:border-b-2
`, {
'px-1 py-3 my-4': dense,
'px-2 py-4 my-6': !dense
})}
>
Hello!
</p>
}

If we were able to raise type errors for all the cases, that'd be the holy grail. Sleeves up!

Implementation

It might look just moderately complex to type the classnames API, but it gets tough. Chewing the complexity into smaller pieces helps. The individual pieces we need for the typings are:

  1. Type containing all valid Tailwind class names. For example type ClassName = 'p-1' | 'p-2' | 'etc'.
  2. Type that trims leading and trailing whitespace of strings.
  3. Error message type that shows which is the offending token. For example 'flexxx' is not a valid Tailwind class.
  4. A way to split the whole className string into word tokens and validate them against ClassName.
  5. Support for a class name string
  6. Support for the object format
  7. Support for the array format

Let's cover them one by one, and tie it all together in the end.

1. Tailwind class names

There's a few different routes for generating a union type out of all class names. One solution would be to use the ability to expand template literal type unions:

type ColorName = 'red' | 'purple' | 'blue' | 'green'
type Luminance = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
type Color = `${ColorName}-${Luminance}`
type Layout = 'block' | 'flex' | 'grid' | 'inline-block'
// Expands to all possible class names
type ClassName = Layout | `text-${Color}` | `bg-${Color}`

But it's a lot of work and the typings would need to be generated based on tailwind.config.js. In addition, it would lead to a huge union type with more than 60 000 unique string literals.

Instead of template literal expansion, I found that postcss-ts-classnames works well. It parses the output of PostCSS, exctracts all class names, and writes the union type of them to a d.ts file.

The result is a robust typing, that doesn't care about Tailwind's configuration or purging. It accurately captures all the class names your project has.

A downside is that we introduce a small lag to the development cycle:

  • A new valid Tailwind class name is taken into use
  • For a short while, IDE complains about the class not being valid
  • PostCSS build is ran, which re-generates the d.ts types
  • IDE does a TypeScript refresh, and the error is gone

That said, the lag isn't a big issue in reality. The union type performs well enough even if your project happens to use a few thousand unique class names.

Now we have tailwindClassNames.d.ts which contains a union type of all valid class names:

type ClassName =
| 'block'
| 'inline-block'
| 'flex'
| 'grid'
| 'bg-gray-100'
| 'p-1'
// ... a thousand more

2. Trimming whitespace

For this trick, we'll use inference with template literals, conditional types, and type recursion. To get started, here's the generously commented TrimStart utility that deals with the leading whitespace.

// Newlines occur in multiline strings with backtick quotes
type Whitespace = ' ' | '\n'
// `extends string` validates that the
// type argument `T` is a string
type TrimStart<T extends string> =
// Is `T` in format: ` ${Tail}` or `\n${Tail}`?
T extends `${Whitespace}${infer Tail}`
// Yes. Recursively call this utility again,
// because Tail might contain new leading
// whitespace.
? TrimStart<Tail>
// No. Stop recursion.
: T

TrimStart loops recursively until it has "eaten" all the whitespace characters from the beginning. You could think it as a while-loop, but done with recursion.

while (str.startsWith(' ') || str.startsWith('\n')) {
str = str.substring(1)
}

In each iteration of the recursive loop, only one whitespace character is consumed at a time. This happens because in the conditional, the type argument T needs to exactly match the template `${Whitespace}${infer Tail}`. Since Whitespace can only be 1 character long (' ' or '\n'), it consumes a single whitespace character at a time.

Here's a few example results (TypeScript playground).

// type Result1 = "A"
type Result1 = TrimStart<' \n A'>
// type Result2 = "A "
type Result2 = TrimStart<' A '>

It's a neat trick, but there is a maximum type instantiation depth limit in TypeScript which limits the recursion depth. It is possible to hit the limit when trying to trim a string with very long leading whitespace.

Fortunately, there's an optimization trick. You can pre-define a few different lengths of whitespaces to minimize the amount of recursion depth required:

type InstantiationDepthReducingWhitespace =
// This will match first, consuming 3 leading spaces at once
| ' '
| ' '
| ' '
| '\n'

Clever! Now let's implement TrimEnd and combine them together:

type InstantiationDepthReducingWhitespace =
| ' '
| ' '
| ' '
| '\n'
type TrimStart<T extends string> =
T extends `${InstantiationDepthReducingWhitespace}${infer Tail}`
? TrimStart<Tail>
: T
type TrimEnd<T extends string> =
T extends `${infer Prefix}${InstantiationDepthReducingWhitespace}`
? TrimEnd<Prefix>
: T
type Trim<T extends string> = TrimEnd<TrimStart<T>>

The end result:

// type Result = "abc"
type Result = Trim<'\n\n abc '>
// The need for trimming long whitespace appears
// in deeply nested (more indentation) multi-line strings:
function doSomething() {
if (condition) {
if (another) {
cn(`
block
p-1
hover:bg-blue
`)
}
}
}

For the curious minds: the type instantiation depth was first increased to 500 but later reduced back to a 100, which isn't a huge amount for template literal processing.

The same pull request, which lowered the type instantiation depth limit to 100, also implemented tail recursive evaluation of conditional types. The description has examples of how the Trim utility could be even further improved.

3. Error messages

The built-in way to indicate a type error is to use never. It works because never is not assignable to any other type.

In complex conditional types you might have multiple instances of never type, but for separate issues. This makes the code hard to read and type errors less descriptive. Unfortunately, TypeScript doesn't natively support anything like throw.

However, there is a hack that achieves almost the same result. Instead of returning never, you can return the error message as a literal string:

type IsValid<T extends string> =
T extends ClassName
? T
: `Error: '${T}' is not a valid Tailwind class`

The literal string error message works in a similar way as never. Types aren't assignable to it in practice. It's easiest to understand via an example (TypeScript playground):

function cn<T extends string>(singleClassName: IsValid<T>) {
return singleClassName;
}
// Argument of type '"invalid"' is not assignable to parameter of type
// '"Error: 'invalid' is not a valid Tailwind class"'.
cn('invalid')

The passed parameter typed as literal 'invalid' is not assignable to the error message that is also a literal string type. This pattern can be extracted as a utility type to make the intention more clear:

// Utility type to provide nicer error messages
type Err<Message extends string> = `Error: ${Message}`

4. Split to tokens and validate

This is going to be a tough piece.

To ease the token processing, let's make a utility that converts className string into an array of tokens. Working with an array is easier than for example a string with space separated values.

The new utility called SplitToTailwindClassNames should do the following conversions:

InputOutput
"block bg-gray-100"["block", "bg-gray-100"]
"\n\n block\n p-1 "["block", "p-1"]
" block invalid "["block", never]

We'll start with the familiar template literal inference. This time instead of consuming whitespace characters, we want to consume Tailwind class names.

type SplitToTailwindClassNames<T extends string> =
// Does `T` start with a Tailwind class name?
T extends `${ClassName}${infer Tail}`
// Yes. Continue consuming ClassName tokens with
// next recursion iteration.
? [ClassName, ...SplitToTailwindClassNames<Tail>]
// No. Stop recursion.
: []

We can see that the utility now knows how to consume Tailwind class name tokens (TypeScript playground).

// type Debug1 = []
type Debug1 = SplitToTailwindClassNames<' '>
// type Debug2 = [ClassName]
type Debug2 = SplitToTailwindClassNames<'block'>
// type Debug3 = [ClassName, ClassName]
type Debug3 = SplitToTailwindClassNames<'blockp-1'>
// Note: whitespace is not correctly handled yet!
// type Debug4 = [ClassName]
type Debug4 = SplitToTailwindClassNames<'block p-1'>

That's great but the array items are of type ClassName, which is the union type itself. What we really need is the actual tokens found, for example ['block', 'p-1'].

There's no way in TypeScript to make a template literal match a token and assign it to a type variable at the same time. If TypeScript supported the feature, it could look something like this:

T extends `${infer C extends ClassName}${infer Tail}`
// `C` would refer to the actual token

But it doesn't.

I couldn't find a solution anywhere online, which lead me to ask the question in Stack Overflow. It took a while to marinate, but finally a solution clicked in my head.

To get access to the first token, you can inverse the inference with a second template literal inference:

type GetTokenTrick<T extends string> =
T extends `${ClassName}${infer Tail}`
? T extends `${infer C}${Tail}`
? C
// This shouldn't be possible to reach, as
// we just inversed the matching
: never
: never

Now the utility captures the first token (TypeScript playground)!

// type Debug = "block"
type Debug = GetTokenTrick<'block<whatever>'>

Let's combine the knowledge so far and also deal with the whitespace by using Trim<Tail>.

type SplitToTailwindClassNames<T extends string> =
// Does `T` start with a Tailwind class name?
T extends `${ClassName}${infer Tail}`
? T extends `${infer C}${Tail}`
// Continue consuming ClassName tokens with
// next recursion iteration.
? [C, ...SplitToTailwindClassNames<Trim<Tail>>]
// This shouldn't be possible to reach, as
// we just inversed the matching
: never
// Stop recursion.
: []

Many cases are now dealt correctly, but there's still a few issues (TypeScript playground).

// type Debug1 = ["block"]
type Debug1 = SplitToTailwindClassNames<'block'>
// type Debug2 = ["block", "p-1"]
type Debug2 = SplitToTailwindClassNames<'block p-1'>
// type Debug3 = ["block", "p-1"]
type Debug3 = SplitToTailwindClassNames<'block\n\np-1 '>
// Still not working:
// Whitespace at the beginning
type Debug4 = SplitToTailwindClassNames<' block'> // []
// Errors are not dealt correctly,
// should be [never]
type Debug5 = SplitToTailwindClassNames<'invalid'> // []

The easiest way to fix the leading whitespace issue is to make a wrapper utility that calls Trim:

type SplitToTailwindClassNames<T extends string> = SplitToTailwindClassNamesInner<Trim<T>>

The error handling can be improved by using the Err type and separating the "invalid T" and "end recursion" cases (TypeScript playground):

type SplitToTailwindClassNamesInner<T extends string> =
T extends `${ClassName}${infer Tail}`
? T extends `${infer C}${Tail}`
? [C, ...SplitToTailwindClassNames<Trim<Tail>>]
: Err<'Should not happen'>
// Added error handling
// Handles cases where `T` does not match
// ${ClassName}${Tail}. For example
// 'block', '', '\n\n', 'invalid', or 'invalid block'
//
// Note: `Tail` has already been trimmed from whitespace
: T extends `${infer Tail}`
? Tail extends ClassName
// `Tail` equals a valid Tailwind class.
// End recursion succesfully.
? [Tail]
: Trim<Tail> extends ''
// `Tail` has only whitespace left.
// End recursion succesfully.
? []
// Something else was found.
// Raise an error
: [Err<`'${Tail}' is not a valid Tailwind class`>]
// Should never happen as `T` is a string.
: [Err<'Should not happen'>]

Finally we got to a version that handles all the cases as expected.

5. String type

Let's turn the pieces above into a utility which validates that a string contains only valid Tailwind classes. The utility type only works if the string is a literal type such as "p-1 block". A generic string type won't suffice, because template literals operate on string constants.

As the first step, let's validate that the input indeed is a literal string type.

type IsValidTailwindClassString<T extends string> =
string extends T
? Err<'Unexpected generic string'>
// Make sure each item is a valid `ClassName`
: SplitToTailwindClassNames<T> extends ClassName[]
// If yes, success and return the type `T` itself
? T
// If no, raise an error
: Err<'Invalid Tailwind class string'>

Now when the utility is invoked with a generic string, we get an error:

// INCORRECT
const str: string = 'generic string'
// type Debug = "Error: Unexpected generic string"
type Debug = IsValidTailwindClassString<typeof str>
// CORRECT
const str2 = 'p-1'
type Debug2 = IsValidTailwindClassString<typeof str2>
// type Debug2 = "p-1"

Also, we correctly get an error in case there's an invalid class name:

// type Debug = "Error: Invalid Tailwind class string"
type Debug = IsValidTailwindClassString<`
block
bg-green-10c
`>

That's already awesome, but let's change it to show which tokens where invalid for a better developer experience. We could for example return the actual array that contains the correct Tailwind classes and the error (TypeScript playground).

type IsValidTailwindClassString<T extends string> =
string extends T
? Err<'Unexpected generic string'>
// Make sure each item is a valid `ClassName`
: SplitToTailwindClassNames<T> extends ClassName[]
// If yes, success and return the type `T` itself
? T
// If no, raise an error
- : Err<'Invalid Tailwind class string'>
+ : SplitToTailwindClassNames<T>

It would work, but the array format can contain valid class names which would make the actual error message harder to spot.

// type Debug5 = ["block", "p-1", "Error: 'invalid' is not a valid Tailwind class"]
type Debug5 = IsValidTailwindClassString<'block p-1 invalid'>

To fix that, we can get the first error from the array. Fortunately we're dealing with just a flat array!

// Gets the first string of an array that starts with 'Error: '
// Must be used only when `T` actually includes an error item
type GetFirstError<T extends unknown[]> =
T extends [infer Head, ...infer Tail]
? Head extends `Error: ${infer Message}`
// Match found, return
? Head
// Continue searching for an error string
: GetFirstError<Tail>
: never

Let's add it to the string-validating utility and call it done (TypeScript playground).

type IsValidTailwindClassString<T extends string> =
string extends T
? Err<'Unexpected generic string'>
// Make sure each item is a valid `ClassName`
: SplitToTailwindClassNames<T> extends ClassName[]
// If yes, success and return the type `T` itself
? T
// If no, raise an error
- : Err<'Invalid Tailwind class string'>
+ : GetFirstError<SplitToTailwindClassNames<T>>

After the change, the error looks as expected.

// type Debug5 = "Error: 'invalid' is not a valid Tailwind class"
type Debug5 = IsValidTailwindClassString<'block p-1 invalid'>

Everything we've built so far has been now combined into a single utility type called IsValidTailwindClassString. That was a lot of work, but we still have a few other formats to cover.

6. Object type

In the object format, each key is a string of class name(s) and the value is a truthy or falsy value indicating if the class names should be included in the final class name.

const dense = true
const classNames = cn({
'p-1 m-1': dense,
'p-3': !dense
})
// classNames = "p-1 m-1"

We need to iterate through each key-value pair in the object, and validate that the key consists of valid Tailwind classes. "Iterating" objects in TypeScript can be done with mapped types. Let's look at a simpler example where we convert a given input object type values to nullables.

type MakeNullable<T> = {
// "For each key (K) in object (T)"
[K in keyof T]: T[K] | null
}

Now we can use MakeNullable to make all object's values nullable (TypeScript playground).

type Input = { a: number, b: boolean }
// type Result = {
// a: number | null;
// b: boolean | null;
// }
type Result = MakeNullable<Input>

Using the knowledge, it's not a huge leap to validating Tailwind class names in keys. We already have IsValidTailwindClassString utility type which checks if a given string consists of valid Tailwind classes or not.

// Use `any` because classnames uses truthiness check,
// and does not require booleans
type TailwindClassNamesObject<T extends { [key: string]: any }> = {
[K in keyof T]: K extends IsValidTailwindClassString<K>
? T[K]
// If invalid class found, return the error
: IsValidTailwindClassString<K>
}

It's that simple! Well, almost.. If you look at the type in TypeScript playground, you can see the following error.

Type 'K' does not satisfy the constraint 'string'.
Type 'keyof T' is not assignable to type 'string'.
Type 'string | number | symbol' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.(2344)

Even when we explicitly defined that the input object can have only string keys, TypeScript will complain about it. The workaround provided in this PR comment is to use & string to filter out all non-string named properties.

Fortunately the trick works like a charm (TypeScript playground).

// Use `any` because classnames uses truthiness check,
// and does not require just booleans
type TailwindClassNamesObject<T extends { [key: string]: any }> = {
[K in keyof T & string]: K extends IsValidTailwindClassString<K>
? T[K]
// If invalid class found, return the error
: IsValidTailwindClassString<K>
}

The errors are correctly reported as we can see from the example.

// type Result1 = {
// "p-1": true;
// flexx: "Error: 'x' is not a valid Tailwind class";
// }
type Result1 = TailwindClassNamesObject<{
'p-1': true,
'flexx': false,
}>

7. Array type

Finally we have the array format. Each item in the array is a string of class names that we need to validate.

const classNames = cn(['block', 'p-1 m-1'])
// classNames = "block p-1 m-1"

Turns out, you can manipulate the type of each individual array item using the same mapped type syntax as for objects. Let's use the same MakeNullable we used as an example in the object format to demonstrate (TypeScript playground).

type MakeNullable<T> = {
// "For each index (K) in array (T)"
[K in keyof T]: T[K] | null
}
// type Result = [number | null, boolean | null]
type Result = MakeNullable<[number, boolean]>

Sometimes TypeScript is just magnificent. The syntax looks a bit odd, but one could argue that arrays do have object-like properties in JavaScript.

The final array type looks very similar to the object one, but with a few minor differences (TypeScript playground).

type TailwindClassNamesArray<T> = {
[K in keyof T]: K extends IsValidTailwindClassString<T[K] & string>
? T[K]
// If invalid class found, return the error
: IsValidTailwindClassString<T[K] & string>
}

Instead of validating just K (the key in objects), we validate T[K] which refers to the array item itself. You could think the array as an object where the keys (usually referred as indices) are numeric starting from 0.

The & string trick is required, but in a bit different context. The array item needs to "casted" into a string type, so that TypeScript is happy. Again, having T extends string[] constraint does not help here.

Combining everything

With the individual pieces solved, it's time to put everything together.

Each parameter of the classnames function can be a string, object, or an array. To allow the same flexibility with strict types, we need a type utility that validates the input based on its shape. Conditional type it is.

type TailwindClassParameterValue<S> = S extends string
? IsValidTailwindClassString<S>
: S extends any[]
? TailwindClassNamesArray<S>
: S extends { [key: string]: any }
? TailwindClassNamesObject<S>
: // Format not supported
never;

As you can see, nested ternaries are not that pleasant to read. Unfortunately it's all we got at type level.

Now, the last part. Typings for the cn function. Prepare to see a bit of an ugly trick..

export function cn<S1, S2, S3, S4, S5>(
c1?: TailwindClassParameterValue<S1>,
c2?: TailwindClassParameterValue<S2>,
c3?: TailwindClassParameterValue<S3>,
c4?: TailwindClassParameterValue<S4>,
c5?: TailwindClassParameterValue<S5>,
// If more than 5 class parameters are required, simply add new ones
): string {
return classnames(c1, c2, c3, c4, c5)
}

Voila! It's not pretty, but having the copy-pasted type parameters allows TypeScript to infer and validate the arguments individually.

That's the solution! I hope this was educational. Links to the final results:

Credits & further reading

The final types took quite a while to finish, and I couldn't have done it without help. Credits to @ahejlsberg, @virtualkirill, and @jcalz for terrific online resources.

Utilities

We ended up implementing numerous advanced TypeScript tricks from scratch. It might make sense to use an existing library instead. There are lodash-like utility packages for TypeScript types too:

Call to action here. Just kidding, I'm doing this for fun. For now..

Thank you!

You should get a confirmation email soon. I'll keep the content coming up!

- Kimmo

Like the content?

Let me know by subscribing to new posts.