Type-Safe Design Tokens

Making TypeScript your source of truth

May 2024

Here's a typical design system component:

interface TextProps {
  color?: string  // accepts anything
  size?: string
  children: React.ReactNode
}

This compiles but also accepts "bleu-500", "text-blue-500" (the full class instead of just the token), "hotpink", and "". Not what we want in our design system.

Template literal types

TypeScript 4.1 introduced template literal types: the ability to construct union types from string combinations using the same backtick syntax as JavaScript. For example:

type Color = 'slate' | 'blue' | 'emerald' | 'rose' | 'amber'
type Shade = '50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'

type ColorToken = `${Color}-${Shade}`
// Resolves to 50 valid strings:
// "slate-50" | "slate-100" | ... | "amber-900"

TypeScript computes all valid combinations for us here and every invalid combination is a compile error.

Building the token system

Let's build this out for a real design system. Start with our primitive scales:

// tokens.ts
export type Color =
  | 'slate'
  | 'blue'
  | 'emerald'
  | 'rose'
  | 'amber'
  | 'violet'

export type Shade =
  | '50' | '100' | '200' | '300' | '400'
  | '500' | '600' | '700' | '800' | '900'

export type FontSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl'
export type FontWeight = 'normal' | 'medium' | 'semibold' | 'bold'
export type Spacing = '0' | '1' | '2' | '3' | '4' | '6' | '8' | '10' | '12' | '16'

// Primitive token types
export type ColorToken = `${Color}-${Shade}`
export type PaddingToken = `p-${Spacing}` | `px-${Spacing}` | `py-${Spacing}`

Now add semantic tokens on top. These are the named roles — primary, destructive, muted — that sit above the raw scale:

// tokens.ts (continued)
export type SemanticColor = 'primary' | 'secondary' | 'destructive' | 'muted' | 'accent'

// Semantic + primitive combined
export type TextColorToken = `text-${ColorToken}` | `text-${SemanticColor}`

Now our component prop types write themselves:

// Text.tsx
import type { TextColorToken, FontSize, FontWeight } from './tokens'

interface TextProps {
  color?: TextColorToken   // ✅ "text-blue-500" | "text-primary" | ...
  size?: FontSize          // ✅ "xs" | "sm" | "base" | ...
  weight?: FontWeight      // ✅ "normal" | "medium" | ...
  children: React.ReactNode
}

export function Text({
  color = 'text-slate-700',
  size = 'base',
  weight = 'normal',
  children
}: TextProps) {
  return (
    <p className={`${color} text-${size} font-${weight}`}>
      {children}
    </p>
  )
}

Usage:

// ✅ All valid — full autocomplete in our editor
<Text color="text-blue-500" size="lg" weight="semibold">Hello</Text>
<Text color="text-primary" size="sm">Muted label</Text>

// ❌ Type errors — caught before runtime
<Text color="text-blue-999">Bad shade</Text>
<Text color="text-blu-500">Typo</Text>
<Text size="large">Invalid size</Text>

The satisfying is in the autocomplete in any editor with TypeScript language server support. The moment we type color="text- into a prop, your IDE offers every valid combination. We get both type safety and the ability to ship faster.

Try the interactive token builder:

Color
Shade
Generated token
`${Color}-${Shade}`
select a color and shade →
Full union — all 90 valid tokens
slate-50slate-100slate-200slate-300slate-400slate-500slate-600slate-700slate-800slate-900blue-50blue-100blue-200blue-300blue-400blue-500blue-600blue-700blue-800blue-900emerald-50emerald-100emerald-200emerald-300emerald-400emerald-500emerald-600emerald-700emerald-800emerald-900rose-50rose-100rose-200rose-300rose-400rose-500rose-600rose-700rose-800rose-900amber-50amber-100amber-200amber-300amber-400amber-500amber-600amber-700amber-800amber-900violet-50violet-100violet-200violet-300violet-400violet-500violet-600violet-700violet-800violet-900orange-50orange-100orange-200orange-300orange-400orange-500orange-600orange-700orange-800orange-900cyan-50cyan-100cyan-200cyan-300cyan-400cyan-500cyan-600cyan-700cyan-800cyan-900fuchsia-50fuchsia-100fuchsia-200fuchsia-300fuchsia-400fuchsia-500fuchsia-600fuchsia-700fuchsia-800fuchsia-900
TypeScript
type Color = 'slate' | 'blue' | 'emerald' | 'rose' | 'amber' | 'violet' | 'orange' | 'cyan' | 'fuchsia'

type Shade = '50' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'

type ColorToken = `${Color}-${Shade}`
// resolves to 90 valid combinations

Tailwind's static analysis

A potential challenge: Tailwind's JIT (Just-in-Time) compiler, the default and only engine in v3+, scans our source files for class strings at build time. But it does this with a simple regex; it can't evaluate JavaScript expressions. It only keeps the CSS classes that are hard-coded in our app. Dynamic class construction breaks it:

// Tailwind won't include these in the output CSS
const className = `text-${color}-${shade}`  // JIT never sees "text-blue-500"

We have two good options around this.

A bit verbose but a lot of type safety.

import type { ColorToken } from './tokens'

const textColorMap: Record<ColorToken, string> = {
  'blue-500': 'text-blue-500',
  'blue-600': 'text-blue-600',
  'rose-500': 'text-rose-500',
  // ... all combinations
}

// Or generate it:
const colors = ['slate', 'blue', 'emerald', 'rose', 'amber'] as const
const shades = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const

export const textColorMap = Object.fromEntries(
  colors.flatMap(color =>
    shades.map(shade => [
      `${color}-${shade}` as ColorToken,
      `text-${color}-${shade}`
    ])
  )
) as Record<ColorToken, string>

Option 2: Tailwind safelist

Add our token patterns to tailwind.config.js:

// tailwind.config.js
module.exports = {
  safelist: [
    {
      pattern: /^text-(slate|blue|emerald|rose|amber)-(50|100|200|300|400|500|600|700|800|900)$/,
    }
  ]l
}

This works but safelists can bloat our CSS bundle if we're not careful with the pattern scope. The lookup map approach keeps bundle size tight and makes the token surface explicit.

Going further: satisfies for token maps

If we're on TypeScript 4.9+, the satisfies operator is nice here because validates that our map covers the full token union while still inferring the literal types:

const textColorMap = {
  'blue-500': 'text-blue-500',
  'rose-600': 'text-rose-600',
  // ...
} satisfies Record<ColorToken, string>
//  ^ Error if we're missing any token
//  ^ Still infers the literal string types (not just `string`)

It's a tighter constraint than as Record<ColorToken, string> because it catches gaps in our map at definition time rather than at call sites.

Putting it all together

Here's a complete Box component using the full token system:

// Box.tsx
import type { ColorToken, Spacing } from './tokens'

type PaddingToken = `p-${Spacing}` | `px-${Spacing}` | `py-${Spacing}`
type BgColorToken = `bg-${ColorToken}`

interface BoxProps {
  bg?: BgColorToken
  padding?: PaddingToken
  children: React.ReactNode
  className?: string
}

export function Box({ bg, padding, children, className }: BoxProps) {
  return (
    <div className={[bg, padding, className].filter(Boolean).join(' ')}>
      {children}
    </div>
  )
}

// Usage — fully typed, fully autocompleted
<Box bg="bg-slate-50" padding="px-6">
  <Text color="text-slate-900" size="lg">Well typed.</Text>
</Box>

Is it worth it?

Template literal types turn our design tokens from a suggestion into a source of truth enforced by code (TypeScript, in this case).

Yeah, it can be tedious to put every design token into a lookup map. Only you can answer the question of whether it's worth it for you. Personally, I think it is for the refactoring confidence it provides.