typescripttailwind-cssclass-names

How to type check if object keys conform a conditional recursive template type?


The question is easy to understand with an example. I'd want to achieve a strictly type guarded wrapper for https://npmjs.com/package/classnames, to type check the Tailwind class names that our app uses.

So far the closest solution is this example:

// Credits to https://dev.to/virtualkirill/make-your-css-safer-by-type-checking-tailwind-css-classes-2l14
type Colors = "red" | "purple" | "blue" | "green";
type Luminance = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type BgColor = `bg-${Colors}-${Luminance}`;
type Layout = "block" | "w-1" | "h-1";
type TailwindClass = BgColor | Layout;

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

type ValidTailwind<T> = T extends ValidTailwindClassSeparatedBySpace<T> ? T : never;
type ClassNames<R> = keyof R extends ValidTailwind<keyof R> ? R : never;

function classNamesWrapper<R>(obj: ClassNames<R>): string {
  // All arguments would be passed to npmjs.com/package/classnames
  // For the example, just return empty string.
  return '';
}

classNamesWrapper({ 
  "bg-red-100": true, 
  "block w-1": true 
});

classNamesWrapper({ 
  "bad-class": false,  // only this key should be invalid
  "block h-1": true
});

The example code available here: Playground link

This works, but the error is not tied to the specific key of the object, but rather all of the object keys. TypeScript will highlight also "block h-1" to have the same error: Type 'boolean' is not assignable to type 'never'..

How could the typing be done so that TS would be able to detect that only the "bad-class" key is invalid Tailwind class string, but not highlight the "block h-1" as invalid?


Solution

  • To fix the specific issue you're seeing I'd change ClassNames to this:

    type ClassNames<R> = { [K in keyof R]: K extends ValidTailwind<K> ? R[K] : never };
    

    Instead of ClassNames<R> returning either R or never, it maps each property name K in R to either R[K] (the property that was already there) or never. Therefore the error should appear only on the offending properties:

    classNamesWrapper({
      "bad-class": false,  // error!
      //~~~~~~~~~ <-- Type 'boolean' is not assignable to type 'never'
      "block h-1": true // okay
    });
    

    (Please note that I haven't spent any time looking at the rest of the example code to see if it is reasonable; I've focused specifically on the question as asked.)

    Playground link to code