Supercharging CSS modules with TypeScript
Tags: typescript, react
Published: 2023-04-17 14:55:23

CSS modules are a means of organizing styles that allows structuring them parallel to component modules. Several modern frameworks such as Next.js, Vue/Nuxt, and Astro even include built-in support. In this post, we'll be looking at how we can make CSS modules into an even more powerful tool in conjunction with more accurate TypeScript typings.

## CSS Module Usage Basics

If you haven't seen CSS modules before, the main premise is that they allow you to define code in CSS files with the `.module.css` extension. Each module will be compiled into uniquely-prefixed class names to provide isolation from other CSS resources.

```css
.myClass {
  /* Styles here */
}
```

The resulting prefixed class names can then be referenced by importing the CSS module within JavaScript or TypeScript:

```tsx
import styles from "./Foo.css";

export const Foo = () => (
  <div className={styles.myClass}>Hello world</div>
);
```

**Note:** Camel-case class names are recommended in order to reference them easily within JS/TS.

Many frameworks also optionally support Sass, allowing you to define modules in `.module.scss` files as well. Note that CSS modules and Sass' own module system are independent from one another, and you can take advantage of both simultaneously.

Here are some resources with more information about CSS modules:

- [General information about CSS modules](https://github.com/css-modules/css-modules)
- [CSS modules in Next.js](https://nextjs.org/docs/basic-features/built-in-css-support#adding-component-level-css)
- [CSS modules in Astro](https://docs.astro.build/en/guides/imports/#css-modules)
- [CSS modules in Vue single-file components (SFCs)](https://vuejs.org/api/sfc-css-features.html#css-modules)

## CSS Modules and TypeScript

Unfortunately, in my experience with Next.js at the time of writing, CSS modules come with a vague `{ readonly [key: string]: string }` type out of the box. This is enough to make them workable in TypeScript, but doesn't provide any particular benefits.

If we were able to get more specific typings based on the contents of our CSS modules, we could catch typos and get autocomplete when consuming them in JS/TS modules, and perhaps even do far more powerful things with them...

Fortunately, there are a few projects that may help with this:

- [typings-for-css-modules-loader](https://github.com/TeamSupercell/typings-for-css-modules-loader) (webpack loader)
- [typed-css-modules](https://github.com/Quramy/typed-css-modules) (CLI)
- [typed-scss-modules](https://github.com/skovy/typed-scss-modules) (CLI for SCSS, inspired by typed-css-modules)

There are undoubtedly multiple ways to get this working; I opted for `typed-scss-modules`, since I was planning to take advantage of some Sass features such as mixins, and I'm using Next.js which already configures CSS modules itself by default.

### Running `typed-scss-modules` with Next's Dev Server

I used an approach akin to `webpack-shell-plugin-next` to run `typed-scss-modules` along with `next dev`, by adding the following to my `next.config.js`:

```js
// Prevent running typed-scss-modules more than once
let isTapped = false;
class TypedScssModulesPlugin {
  apply(compiler) {
    if (isTapped) return;
    compiler.hooks.afterPlugins.tap("TypedScssModulesPlugin", () => {
      require("child_process").spawn(
        "npm", ["run", "tsm:watch"], { stdio: "inherit" }
      );
    });
    isTapped = true;
  }
}

const nextConfig = {
  // ...
  webpack(config, { dev, webpack }) {
    if (dev) {
      config.plugins.push(
        new TypedScssModulesPlugin(),
        new webpack.WatchIgnorePlugin({
          paths: [/scss\.d\.ts$/]
        })
      );
    }
    // ...
    return config;
  },
};

module.exports = nextConfig;
```

The above config relies on a `scripts` entry in `package.json` with the following definition:

`"tsm:watch": "typed-scss-modules components --includePaths styles --exportType default --watch"`

In the above arguments, `components` is the folder where all of my `.module.scss` files reside, and `styles` is where I have common Sass modules that I want to be able to `@use` without specifying a path.

Now when you run `next dev`, you should see messages about `.module.scss` file changes being detected and `.module.scss.d.ts` files being generated. After they've been generated, you should see improvements to intellisense for CSS module imports - namely, it should show you _exactly_ what class names are available, and report nonexistent class name references as errors.

## Building Component Props from CSS Module Typings

Working autocomplete and typo prevention are a great start, but the use case that sent me down this path was more ambitious. I was working on building up a UI library to replace a runtime CSS-in-JS solution to reduce overhead, but wanted to replicate some of its concepts, such as color schemes, sizes, and variants (different styles of the same component, e.g. solid vs. outlined).

I wondered, could I get to the point where typings for each separate prop are generated based on the SCSS with no manual repetition required across TS and SCSS files? Could I maybe even define this all within a single CSS module, in order to guarantee that each "dimension" out-specifies base styles?

Fortunately, with some digging and experimenting, the answer was yes!

### The Component Design

Let's say we want to design a button component that allows simultaneously specifying the following:

- Color scheme, either blue or red
- Size, either small or medium
- Variant, either solid or outline

### The SCSS

We'll start with the following class names. Note that all dimensions' styles are nested to guarantee they out-specify base styles - in my experience with Next.js at least, this can end up being necessary because builds can scramble CSS selector declaration order.

```scss
.button {
  /* Base styles */

  &.variantSolid {
    /* Common styles for variant */

    &.colorBlue {
      /* Color-scheme-specific styles for variant */
    }

    &.colorRed {
      /* Color-scheme-specific styles for variant */
    }
  }

  &.variantOutline {
    /* Repeat the same structure as above */
  }

  &.sizeSm {
    /* Styles for small size */
  }
  &.sizeMd {
    /* Styles for medium size */
  }
}
```

### The Prop Typings

With these styles in place and their typings generated, we want to define props for each dimension. Ideally, we want to build their types based on the generated CSS module typings, in such a way that we don't have to manually keep them in sync across TypeScript and SCSS.

Notice that I used a common prefix for all of the class names for each dimension. This will enable TypeScript to do all the work of picking out the appropriate values for us, by combining a few very useful but possibly-obscure features:

- [String template literal type inference](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html#inference-with-template-literals)
- [`infer` within `extends` for template string types](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-8.html#improved-inference-for-infer-types-in-template-string-types) (the most specific docs for this I could find are from one of TypeScript's "What's New" articles, not a handbook page, unfortunately)
- [The `Uncapitalize` utility type](https://www.typescriptlang.org/docs/handbook/utility-types.html#uncapitalizestringtype)

The following type allows us to pass it an entire `styles` type and get back a string union type with only the keys having a particular prefix, with the prefix removed and the first letter of the remaining string changed to lowercase:

```ts
export type PrefixedKeys<Map extends {}, Prefix extends string> =
  Extract<
    keyof Map,
    `${Prefix}${string}`
  > extends `${Prefix}${infer S}`
    ? Uncapitalize<S>
    : never;
```

This allows us to define our dimensions as follows:

```tsx
import styles from "./Button.module.scss";

type ButtonColorScheme =
  PrefixedKeys<typeof styles, "color">;
type ButtonSize =
  PrefixedKeys<typeof styles, "size">;
type ButtonVariant =
  PrefixedKeys<typeof styles, "variant">;

export interface ButtonProps {
  colorScheme?: ButtonColorScheme;
  size?: ButtonSize;
  variant?: ButtonVariant;
}

export const Button = ({
  colorScheme = "blue",
  size = "md",
  variant = "solid"
}) => (...);
```

This will allow us to optionally specify these dimensions when creating instances of the component:

```tsx
<Button colorScheme="red" variant="outline">
  Remove
</Button>
```

### The Implementation

Hopefully this seems pretty exciting already, but there's one remaining hurdle: due to how specific the typings in `styles` are, if you attempt to casually reattach the provided string to the prefix, you're likely to get TypeScript errors. How do we implement the actual bindings of these values to members of `styles` in a type-safe way?

Fortunately, we can define another function to help reconstruct the typings for this:

```ts
export const prefixValue =
  <P extends string, S extends string>(prefix: P, value: S) =>
    `${prefix}${value[0].toUpperCase()}${value.slice(1)}` as `${P}${Capitalize<S>}`;
```

When we pass the prefix and value to this function, it explains to TypeScript that the result is the specified prefix followed by the specified string with its first letter capitalized, effectively accomplishing the reverse of how we picked the class names out of `styles` to create our dimensions' typings before.

We can use this function to implement our component's `className` prop as follows (for simplicity's sake, this assumes usage of the [classnames package](https://www.npmjs.com/package/classnames)):

```tsx
return (
  <button
    className={classNames(
      styles.button,
      styles[prefixValue("color", props.colorScheme)],
      styles[prefixValue("size", props.size)],
      styles[prefixValue("variant", props.variant)],
      props.className // Allow passing through more classes
    )}
  >
    {children}
  </button>
);
```

## Conclusion

In this post, I aimed to demonstrate how with the right tools, CSS modules and TypeScript can be used as a basis for configurable core UI components, with no runtime CSS-in-JS required. With developers becoming increasingly-conscious of [the performance penalties incurred by runtime CSS-in-JS](https://dev.to/srmagura/why-were-breaking-up-wiht-css-in-js-4g9b), this is one potential alternative worth considering.