Background

static typing

It’s been widely known to the programming languages community for decades that static type systems help detect bugs. It’s unsurprising, then, to see the rise of TypeScript over the course of the past decade. With JavaScript and TypeScript in particular, a 2017 study found that simply adding type static type annotations caught 15% of public bugs. Static types have also been shown to have a significant positive effect on API usability, more so than explicit documentation while also having a positive interaction effect with explicit documentation. In total, this means libraries have a critical role in ensuring they support some form of static type checking, whether TypeScript or the less popular Flow, to help minimize the risk of bugs and make the libraries easier to use for end developers.

atomic css

In recent years, https://tailwindcss.com/ and other “Atomic CSS” frameworks have seen significantly increased interest within the frontend development community, with Tailwind jumping from 34% awareness in 2019 to 96% by 2022 according to the 2022 State of CSS Survey, and being the second most used CSS framework - still well behind Bootstrap, which is used by over 80% of developers still, but regardless used over twice as often as the third most used CSS framework (Materialize CSS, at 23%) at 46.1% usage rate, compared to its sub-10% usage rate in 2019.

Atomic CSS frameworks promise improved development speeds and CSS bundle sizes through the use of composing styles with a large number of utility classes, as opposed to traditional “semantic” CSS. However, atomic CSS frameworks come at the cost of the loss of static typechecking for their classes: while some of the more established frameworks like Tailwind may have IDE integrations to help autocomplete class strings, they nonetheless provide no actual verification that the class strings only contain valid classes, leaving typos hidden in the code only to be detected much later when the behavior observed appears to differ from what is expected.

class combiners

For traditional and atomic css approaches alike, developers often use utility functions to help with the construction of class names for their components. classnames and clsx are two of the most popular solutions for this, with each package seeing over 10 million downloads on npm per week. However, while they do have TypeScript typings, these types are not very precise, as the behavior of the function is not modelled in their type signatures, instead generalizing both the inputs and outputs of the functions to broadly strings. However, in the domain of class names, which these tools are typically used for, many of these strings are known beforehand at compile time and therefore should be able to be encoded in the function’s typing.

TS Playground - An online editor for exploring TypeScript and JavaScript

see: classnames and clsx typings

solution

back-to-class is a competing package to classnames and clsx that provides and extensible, strictly-typed, type-safe API. It exports a single function, create, that, given a type parameter, constructs a type-safe version of classnames or clsx for the given type.

If users are using a constrained, limited set of class names in their application, they can encode this set of valid class names in a type and pass it into create to get a class string combiner function that only permits class strings that satisfy the set of valid class names. For example,

const cl = create<'a' | 'b' | 'c'>()
// error: 'd' is not assignable to 'a' | 'b' | 'c'!
cl('a', 'b', 'c', 'd')

technical details

infinite types

However, some class name constructions cannot be encoded as a union of TypeScript strings. For example, TypeScript cannot create a type representing a possibly infinitely recursing string (i.e. a regular grammar). It is possible, however, to construct a type that “parses” a given generic string literal (aside: in fact, since TypeScript generics are Turing-Complete, this means it can theoretically parse any grammar). It’s thus possible to create a “validator” type for a possibly infinite string, and even embed this validator into the definition of the generic itself to introduce “user-imposed constraints” in TypeScript.

did you just read the paragraph and see a bunch of words that made no sense? check out this TS playground that tries to explain the basics with some examples

higher-kinded types

User-imposed constraints are powerful, but require building the type yourself to introduce the constraint. For a library where we want to let users define their own constraints, user-imposed constraints are thus unsuitable for the task, and TypeScript does not have inherent support for arbitrary polymorphic values (for example, if we have a type F<X>, we can’t just pass F somewhere and give it X later).