DEV Community

Kerry Boyko
Kerry Boyko

Posted on

A little trick for CSS/SCSS module safety

This is a tip for those using CSS Modules. You know, things like:

import styles from './componentStyles.module.scss'

While we may never have anything near the "type safety" of TypeScript in styling with SCSS or CSS, we can take advantage of a quirk in how CSS Modules work to make it so that we will be warned if we try to access a style in our stylesheet that is undefined.

This actually comes up quite a bit - you may have a defined style (in BEM syntax) of something like .home__button__main { but reference it in your React code as
<button className={styles.home__main__button}>Text</button>. Or any number of typos. The point is, if you try to access a value on the styles object that is undefined, it will return undefined which is a valid value, and which will be interpreted by your browser as "undefined" leading to elements having class .undefined.

Wouldn't it be great if we could get our browser to throw an error at build time or run time if we attempted to use a property on styles that just wasn't there?

We can.

This is because style modules are interpreted in the JavaScript code as objects. In Typescript, they'd be considered type StyleModule = Record<string, string>

Here's the cool bit. In JS, there's a rarely used set of keywords: "get" and "set". Getters and setters often make code more complicated and hard to follow - that's why they're used sparingly, and many people prefer the syntax of creating a getter function. Setters are even more confusing, because they can execute arbitrary code logic whenever assigning a variable. There are a few cases in which this might be useful. For example:

class Weight {
  constructor(private value: number){}

  get kilograms (){
    return this.value;
  }
  get pounds (){
    return this.value * 2.2;
  }
  set kilograms (value: number){
    this.value = value;
  }
  set pounds = (value: number) {
    this.value = value / 2.2
  }
}

const weight = new Weight (10);
console.log(weight.kilograms); // 10
console.log(weight.pounds); // 22.0
weight.kilograms = 5;
console.log(weight.pounds); // 11
weight.pounds = 44
console.log(weight.kilograms); // 20
Enter fullscreen mode Exit fullscreen mode

And so on and so forth.

Using this, we can actually run code on setting or retrieving values, and change the output conditionally. So what does this mean?

It means we can write something like this:

export const makeSafeStyles = (
    style: Record<string, string>,
    level: "strict" | "warn" | "passthrough" = "strict"
): Record<string, string> => {
    const handler = {
        get(target: Record<string, string>, prop: string, receiver: any): any {
            // if element is defined, return it. 
            if (prop in target) {
                return Reflect.get(target, prop, receiver);
            }
            // otherwise, 
            if (level === "strict") {
                throw new TypeError(`This class has no definition: ${prop}`);
            }
            if (level === "warn") {
                console.warn(
                    `This class has no definition: ${prop}. Defaulting to 'undefined ${prop}`
                );
            }
            // we should 
            return `undefined_class_definition ${prop}`;
        },
    };
    return new Proxy(style, handler);
};
Enter fullscreen mode Exit fullscreen mode

The result: When you use an undefined style in a project... this happens.

Image description

I can go into the code and see:

 <div className={styles["progress-bar-outer"]}>
                <div
                    className={styles["progress-inner-bar"]}
                    style={{ width: `${formCompletionPercentage * 100}%` }}
                ></div>
            </div>
Enter fullscreen mode Exit fullscreen mode

and to my module and see

.progress-bar-outer {
    position: relative;
    width: 100%;
    height: $progress-bar-height;
    background-color: get-palette("progress-bar-background");
    margin: 0;
}
.progress-bar-inner {
    position: absolute;
    width: 0%;
    height: $progress-bar-height;
    transition: width ease-out 0.25s;
    background-color: get-palette("progress-bar-fill");
}
Enter fullscreen mode Exit fullscreen mode

And I immediately know that there is a typo or spelling error that is simply fixed by correcting the line:

 <div className={styles["progress-bar-outer"]}>
                <div
                    className={styles["progress-bar-inner"]}
                    style={{ width: `${formCompletionPercentage * 100}%` }}
                ></div>
            </div>
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
teetotum profile image
Martin

Wonderful idea. How do you use makeSafeStyles? Do you wrap the imported style object and try to always only access the wrapped object?

import { makeSafeStyles } from '@/util/makeSafeStyles';
import unsafeStyle from './encabulator.module.scss';
const style = makeSafeStyles(unsafeStyle);

// ...

<div className={classnames(style.componentRoot, style.encabulator, props.className)}> ...</div>
Enter fullscreen mode Exit fullscreen mode

And you could also add a global style that shows you if you ever happen to have a .undefined_class_definition in your DOM:

body:has(.undefined_class_definition) {
  outline: 10px solid red !important;
  outline-offset: -10px;
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
moopet profile image
Ben Sinclair

I never knew that!