A little trick for CSS/SCSS module safety

kerryboyko

Kerry Boyko

Posted on April 5, 2024

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
💖 💪 🙅 🚩
kerryboyko
Kerry Boyko

Posted on April 5, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related