TypeScript Design Tokens with Styled Components
Design tokens are an invaluable tool when building complex interfaces. They provide a foundation for component libraries and inform one-off and future component designs. One of the reasons I love Tailwind so much is that it provides a beautiful set of design tokens right out of the box, but what do we reach for when Tailwind isn't an option or we've outgrown it?
I recently ran into this issue on a TypeScript-based React Native project. While React Native does a fantastic job of abstracting away things like styling, styling components in React Native is not the same as styling components with CSS. Some very talented developers have put a lot of effort into some fantastic tools like tailwind-rn to help with this, but I prefer to use Styled Components as it helps to cut down on visual clutter when building complex views. Because of this, Tailwind was also no longer an option, so I needed to reach for another system for managing design tokens. But how do we manage a custom design token system in Styled Components while maintaining the type-safety that TypeScript provides?
Building a Design System with Types
Surprisingly, this was the easiest part. It turns out TypeScript already has a fantastic tool for handling design tokens: Enums. For example, we can easily define a palette of base colors:
1enum ColorToken { 2 Blue100 = "#dbeafe", 3 Blue200 = "#bfdbfe", 4 Blue300 = "#93c5fd", 5 Blue400 = "#60a5fa", 6 Blue500 = "#3b82f6", 7 Blue600 = "#2563eb", 8 Blue700 = "#1d4ed8", 9 Blue800 = "#1e40af", 10 Blue900 = "#1e3a8a", 11 // even more colors 12}
Next, we can use these color tokens to define a theme to be used by our components via Styled Components' theming support.
1import type { DefaultTheme } from "styled-components"; 2 3declare module "styled-components" { 4 export interface DefaultTheme { 5 textColor: ColorToken; 6 } 7} 8 9const theme: DefaultTheme = { 10 textColor: ColorToken.Blue500; 11}
This gives us a theme based on our design tokens that we can then use in our components:
1const Content = styled.Text` 2 font-color: ${(props) => props.theme.textColor}; 3`;
Taking It a Step Further with Currying and Helpers
This is a great start, but we can make it better. The ${(props) => props.theme.textColor};
pattern is a bit cumbersome and verbose, and as our app grows in size and complexity, we'll soon find ourselves nesting values in our theme to organize it into a hierarchy for maintainability. This means our token keys will become longer and longer. What if we decide we need to do some other processing before returning a token to account for user preferences? Luckily, we can leverage currying to clean things up a bit. I'm going to cheat and use get from lodash-es for simplicity:
1import { get } from "lodash-es"; 2 3interface StyledComponentProps { 4 theme: DefaultTheme; 5} 6 7export const token = (key: string) => (props: StyledComponentProps) => 8 get(props.theme, key);
This helper works by first taking the key
for the value we want out of our theme. It then returns a function that takes the props
object from Styled Components and returns the value. This gives us a convenient helper function that can be used directly in our component to pull back a token:
1const Content = styled.Text` 2 font-color: ${token("textColor")}; 3`;
That cleans things up a bit, and gives us a place to hook into if we need to do some logic before returning a value from our theme. If you look closely, however, we've taken a step back: We no longer have type-safe access to our theme. Rather than accessing the theme object directly, we can send that helper any string we want, and that leaves us open to making mistakes. What can we do about this?
Leveraging Types
In TypeScript, we can utilize unions of string literal types as valid keys for a function argument. However, manually maintaining this list of literals quickly becomes painful and error-prone. Luckily, since TypeScript 4.3, we have a way forward: Recursively generating a type for our path options. We can crawl our theme object and define a union of string literals at compile time and use these as the type for our key
argument in our token()
helper:
1type Path<T extends string> = T extends "" ? "" : `.${T}`; 2 3type PathsOf<T> = ( 4 T extends object 5 ? { 6 [K in Exclude<keyof T, symbol>]: `${K}${Path<PathsOf<T[K]>>}`; 7 }[Exclude<keyof T, symbol>] 8 : "" 9) extends infer D 10 ? Extract<D, string> 11 : never; 12 13type ThemeKeys = PathsOf<DefaultTheme>;
ThemeKeys
is now a union of string literals representing the "leaves" of our theme. We can update our token()
helper to use that type:
1const token = (key: ThemeKeys) => (props: StyledComponentProps) => 2 get(props.theme, key);
And now we have type-safety in our component's theme:
1const Content = styled.Text` 2 /* Works just fine, because the key exists */ 3 font-color: ${token("textColor")}; 4 5 /* Compile error because 'backgroundColor' doesn't exist 6 in our theme yet */ 7 background-color: ${token("backgroundColor")}; 8`;
Where to Go from Here
There are a couple of things we learned here that can be helpful elsewhere:
- Currying can be useful in Styled Components by making additional helpers that rely on values from
theme
orprops
. - Generating types for object keys can be used elsewhere, such as internationalization.