A little trick for CSS/SCSS module safety
Kerry Boyko

Kerry Boyko @kerryboyko

About: Kerry Boyko is a full stack Node/JS/TS developer, she was the CTO and co-creator of the Mayday PAC, which raised $11M in 2014.

Location:
London, UK
Joined:
Oct 30, 2018

A little trick for CSS/SCSS module safety

Publish Date: Apr 5 '24
7 2

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

Comments 2 total

  • Ben Sinclair
    Ben SinclairApr 6, 2024

    I never knew that!

  • Martin
    MartinApr 8, 2024

    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
Add comment