DEV Community

Kenneth G. Franqueiro
Kenneth G. Franqueiro

Posted on • Edited on

Supercharging CSS modules with TypeScript

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.

.myClass {
  /* Styles here */
}
Enter fullscreen mode Exit fullscreen mode

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

import styles from "./Foo.css";

export const Foo = () => (
  <div className={styles.myClass}>Hello world</div>
);
Enter fullscreen mode Exit fullscreen mode

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:

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:

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:

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

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.

.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 */
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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:

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

This allows us to define our dimensions as follows:

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"
}) => (...);
Enter fullscreen mode Exit fullscreen mode

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

<Button colorScheme="red" variant="outline">
  Remove
</Button>
Enter fullscreen mode Exit fullscreen mode

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:

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

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):

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

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, this is one potential alternative worth considering.

Top comments (1)

Collapse
 
arianhamdi profile image
Arian Hamdi

Thank you for your great article!
I'm trying to use sassOptions.prependData in my next.config.js file to automatically import Sass files into my modules. However, when I run the npm command, I receive an error indicating that certain variables are undefined. I've added the plugin to afterPlugins hook, but it hasn't resolved the issue.