Andrei Pfeiffer logo
Back to Articles

The evolution of scalable CSSPart 8: Type-safe CSS

CSS
11 min read

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.

  1. Introduction
  2. Part 1: CSS scalability issues
  3. Part 2: Good practices
  4. Part 3: CSS Processors
  5. Part 4: Methodologies and Semantics
  6. Part 5: Styles Encapsulation
  7. Part 6: Atomic CSS
  8. Part 7: CSS-in-JS
  9. Part 8: Type-safe CSS
  10. 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:


Timeline of scalable CSS evolution, highlighting the CSS-in-JS branch (in yellow) but emphasizing on the emergence of Type-safe CSS around 2018
Pairing static type checkers with CSS-in-JS results in Type-safe CSS

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.

Type safety is the extent to which a programming language discourages or prevents type errors.

Wikipedia

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 values

Most 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 variables

Using 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 classes

When 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.

Code Completion vs. Type CheckingMost code editors and IDEs provide code completion, suggestions, and IntelliSense for CSS rules and valid values, either by default or via plugins. Such features are helpful when writing code as we don't have to manually type the entire code ourselves.

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:

theme.tsTS
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:

component.tsTSX
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".

theme.tsTS
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 refactoring

Let's consider that we'll manually rename the theme object to tokens:

theme.tsTS
- 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?

theme.tsTS
  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 tools

Renaming 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.

theme.tsTS
- import { theme } from "./theme";
+ import { tokens } from "./theme";
Notification.tsxTSX
  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 classes

Searching 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 styles

With 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 extraction

Let'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:

  1. First, we must search for all CSS classes, static or dynamic, used by the extracted component.
  2. For each class, we have to check if it's referenced in the old component as well.
  3. 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.
  4. We'll have to repeat steps 2-3 for all CSS classes used in the extracted component.
  5. Afterward, we have to run the application to make sure we don't have any runtime errors.
  6. Perform a visual check to make sure that all styles are properly applied.
  7. Sometimes, we also have to rename CSS classes to reflect the new name of the extracted component, which involves even more manual labor.
Type-safe extraction

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

Scroll to top