A little trick for CSS/SCSS module safety
Kerry Boyko
Posted on April 5, 2024
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
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);
};
The result: When you use an undefined style in a project... this happens.
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>
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");
}
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>
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
January 22, 2024