The evolution of scalable CSS is a multi-part chronicle intended to document the progress of tools, practices and techniques that enable us to write maintainable CSS at scale.
- Introduction
- Part 1: CSS scalability issues
- Part 2: Good practices
- Part 3: CSS Processors
- Part 4: Methodologies and Semantics
- Part 5: Styles Encapsulation
- Part 6: Atomic CSS
- Part 7: CSS-in-JS
- Part 8: Type-safe CSS
- Epilogue
In the previous article of this series, Part 7: CSS-in-JS, we've covered a third paradigm, besides Semantic CSS and Atomic styles definition. Depending on the specific CSS-in-JS library, we could output styles dynamically or statically, either as atomic or semantic encapsulated classes.
In this part, we'll take CSS-in-JS one step forward, pairing it with TypeScript to solve the missing piece of the puzzle regarding type-safe UI development: type safety for components' styles.
Here's what we'll cover:
- What is type safety?, especially in the context of CSS.
- Typed themes for design systems.
- Typed interfaces for UI components design.
- Safe refactoring, either manually or using specialized tools.
What is type safety?
User interface development using HTML, CSS, and JavaScript lacks type safety by default. Browser engines are designed to ignore most errors and make the best effort to render whatever possible.
Therefore developers cannot rely on tools to highlight potential problems in their code, so they have to be extra cautious during development. This becomes a scalability problem during long-term projects.
Firstly, CSS is not a programming language per se, therefore it doesn't have its own compiler to check for code errors. Instead, CSS makes sense when applied to HTML content, either as static or dynamically manipulated styles using JavaScript.
So let's cover a few categories of type errors that could surface when writing CSS code and see how we could prevent them.
Invalid CSS properties or valuesMost code editors include support for CSS, providing suggestions, code completion, and validating CSS rules and their respective values. But if we want to check the correctness of the code, we'd have to use linters such as Stylelint to enforce valid syntax.
.notification {
/* 🚫 incorrect CSS rule & value */
font-thickness: big;
}
CSS-in-JS libraries that implement Object Syntax for styles definition typically use CSSType to provide type definitions for static type checkers like TypeScript or Flow.
Misspelled CSS variablesUsing nested CSS custom properties or invalid CSS variables is too complex to statically analyze. It's probably impossible to detect such errors with linters or other static analysis tools, not to mention that markup and style can be manipulated at runtime. Therefore, browsers will silently ignore any non-existent CSS variables.
.notification {
/* 🚫 incorrect CSS custom property name */
color: var(--color-missing);
}
With CSS-in-JS we use plain JavaScript variables, which have to exist at compile time to reference them, making them trivial to type check.
Misspelled or missing CSS classesWhen applying CSS classes to HTML elements, we might misspell them or specify a missing class. On the other hand, we could delete a CSS class still used somewhere in the code base. Unfortunately, there are no tools, at least for now, to prevent such type errors.
There's a TypeScript language service plugin for CSS modules that checks if the applied class names are defined in the imported CSS files. However, it has its limitations, and it doesn't support refactoring.
<!-- 🚫 misspelled CSS class name -->
<div class="notification_item">
Since CSS-in-JS solutions work with JS/TS identifiers instead of untyped strings, type checking is trivial since it's performed by the JavaScript language service or by the respective static type checker in the case of TypeScript or Flow.
However, when we make any changes, a type checker is required to re-validate the correctness of the updated code. A typical scenario is when changing a component's interface that is used in multiple places. A type checker would throw errors, while code completion alone would have no effect.
Using CSS-in-JS, paired with a static type checker, unlocks the power of type safety for CSS code, preventing a large class of errors from reaching production code. Additionally, static types enable better component interface design and trivial refactoring which are essential during long-term code maintenance.
Typed themes
Design tokens defined in JavaScript, that can be easily shared between logic and styles, are very useful especially when working with design systems. We could define an entire theme using a plain JavaScript object:
export const theme = {
colors: {
brand: "#f45919",
success: "#00875a",
warning: "#f7b228",
},
breakpoints: {
/*...*/
},
};
The theme can be easily shared across an entire application by explicitly importing it:
import { css } from "css-in-js-library";
import { theme } from "./theme";
const styles = css({
background: theme.colors.success
});
export const Notification = () => (
<div className={styles}>...</div>
);
- we get auto-complete for all of its properties, so we'll never have to remember them;
- we get instant type checking, so we'll never misspell any of its properties.
theme.colors.warning
// ✅ "#f7b228"
theme.colors.warn
// ❌ Property 'warn' does not exist
Additionally, type checking also kicks in whenever someone changes the definition of the theme
. Without having to search for all occurrences, the type system will highlight them, guiding us in solving all type errors.
Typed interfaces
Let's consider a Notification
component that accepts a color
prop. We could set its type to string
. It's not wrong. However, it might be too permissive:
interface NotificationProps {
// ⚠️ too permissive
color: string;
}
- The component's author would have to implement manual checks to validate the input and allow only accepted values.
- Component's consumers could pass any color, like
red
or#ff0000
, which are not defined in our color theme. They will not know what colors are valid unless they manually look at the color palette definition or understand the component's source code.
We can further improve our UI components' interfaces with statically defined themes. For example, by adding an as const
type assertion, we're telling TypeScript that "our theme
object is read-only and will never change".
export const theme = {
/*...*/
} as const;
Now, we can hint to our consumers about what colors are available for the component:
import { theme } from "./theme";
interface NotificationProps {
// ✅ restricted only to defined colors
color: keyof typeof theme.colors;
}
- Consumers will get IntelliSense with all the available color options, so they don't have to remember or type them manually.
- The authors don't have to implement any additional checks, as the type system would throw compile-time errors when passing invalid input.
- Last but not least, typed code is self-documented. Therefore understanding what the code does becomes trivial.
We can even go one step further, by allowing only a subset of colors, for specific use cases:
import { theme } from "./theme";
interface NotificationProps {
// ✅ restricted only to "success" & "warning"
color: keyof Pick<typeof theme.colors, "success" | "warning">;
}
And the best part is that everything is interconnected. Whenever we change the theme.colors
object and rename or remove one of the colors, all component interfaces and consumers that use those components will throw compile errors, highlighting all the problems we need to fix.
With strictly typed interfaces, any component consumer will get a flawless development experience without having to fully understand the implementation details. Moreover, misusing such components is unlikely since the type system validates the consumer input while also providing hints for the correct usage.
Safe refactoring
During long-term projects, refactoring becomes an essential practice. However, we might be reluctant to change any working code without proper tooling to support effortless and confident refactorings. Thankfully, TypeScript is one of the tools that turn refactorings into trivial tasks we can perform on a daily basis.
Manual refactoringLet's consider that we'll manually rename the theme
object to tokens
:
- export const theme = { ... };
+ export const tokens = { ... };
In this case, we'll get instant compile error(s) in all the files that import the old theme
object. Thus, type checking will aid us in manually fixing all the problems without missing any occurrences.
# ✅ instant compile-time errors
Module '"./theme"' has no exported member 'theme'.
With plain JavaScript, this particular error would occur during transpilation, which would be instantly displayed with HMR.
But what if we change the name of a specific theme color?
export const theme = {
colors: {
- brand: "#f45919",
+ primary: "#f45919",
},
};
In the case of TypeScript, we'll also get instant compile-time errors, highlighting all the occurrences that we need to replace.
# ✅ instant compile-time errors
Property 'brand' does not exist.
With plain JavaScript, on the other hand, this particular error would occur only at runtime once the code gets executed. So, unfortunately, it could slip away without even noticing it, surfacing unexpectedly at a later time.
Refactoring toolsRenaming is one of the most common refactorings we usually perform. Using TypeScript, we can perform such refactorings automatically with proper tooling. For example, VSCode provides several refactoring tools, one of them being rename symbol.
Right-clicking the theme
object, selecting "Rename symbol" from the dropdown, and choosing a new name like tokens
will automatically update all imports and all usages of that object. What's left is to Save all files, and we're done.
- import { theme } from "./theme";
+ import { tokens } from "./theme";
interface NotificationProps {
- color: keyof typeof theme.colors;
+ color: keyof typeof tokens.colors;
}
Renaming CSS classes
Since CSS classes are typically handled as strings, there are no refactoring tools available for us to support automatic renaming. On the other hand, manual renaming is quite error-prone since it's not trivial to determine all the usages of a single CSS class or selector.
So, instead of introducing potential bugs, we often choose not to perform CSS refactorings at all. This is unfortunate and problematic because the existing code will only worsen due to entropy without any real option to improve it.
Semantic CSS classesSearching for a CSS class such main-menu__item-link--active
to find its occurrences and rename them isn't that difficult, right? But what if its name is dynamically computed?
const class_names = `main-menu__item-link${active ? "--active" : ""}`;
In this case, we would have a hard time finding out where the class is applied.
With CSS Modules it gets a bit easier, since we typically have a single file per component. However, when working with large components, the same limitations apply.
CSS-in-TS stylesWith TypeScript and CSS-in-JS, we benefit from automatic refactorings out of the box, no matter which library we use. Moreover, even if we perform manual refactorings, we can rely on the type system to point out any potential errors without even running the code.
Extracting components
Without type safety, extracting a component and its styles into a separate file is a daunting task. That might be the reason why we're so reluctant to perform such refactorings.
During component extraction, the most challenging task is making sure that we have moved all the required styles without leaving behind any unused ones.
Type-unsafe extractionLet's go step by step through a manual process of component extraction. Considering we have already extracted the markup and logic into a separate file, we have to do the same for all the used styles:
- First, we must search for all CSS classes, static or dynamic, used by the extracted component.
- For each class, we have to check if it's referenced in the old component as well.
- If it's not referenced, we could move the class to the newly created
.css
file. Otherwise, we must figure out how to share it between the two components. - We'll have to repeat steps 2-3 for all CSS classes used in the extracted component.
- Afterward, we have to run the application to make sure we don't have any runtime errors.
- Perform a visual check to make sure that all styles are properly applied.
- Sometimes, we also have to rename CSS classes to reflect the new name of the extracted component, which involves even more manual labor.
With CSS-in-TypeScript, we have the type system as a sidekick, pointing out which styles need to be moved and which of them are not referenced anymore, so we can safely delete them. Extracting components couldn't be any safer than that.
During the past years, static type checkers like TypeScript have seen a constant increasing adoption, allowing us to write type-safe business logic. Additionally, TSX enables type-safe JSX markup, being officially supported by TypeScript. Finally, CSS-in-TypeScript provides type-safe styles.
With these weapons at our disposal, scalability and maintainability become less problematic, regardless of the number of team members, the codebase's size, or the project's length.
As we're slowly approaching the present day, before ending the chronicle, let's take a step back and synthesize in the Epilogue everything that we've covered so far.
References and further reading
- How TypeScript Helps Enterprise Developers by Mary Branscombe
- Zero-runtime CSS-in-TypeScript with vanilla-extract by Mark Dalgleish