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:
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 combinationsTailwind'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.
Option 1: a lookup map (recommended)
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.