A design system is only as good as its enforcement. You can have beautiful Figma files, meticulously documented components, and a Storybook instance that would make Brad Frost proud. But without type safety, it is only a matter of time before someone passes variant="dager" (typo), uses a primary ghost button, or forgets to pass a required aria-label.
At WEIRDSOFT, every project ships with a type-safe design system. The investment pays for itself in the first week of active development. This post covers the full stack: TypeScript for component contracts, CVA for variant management, automated documentation generation, and the refactoring confidence that makes it all worthwhile.
The Foundation: TypeScript Component Contracts
The core of a type-safe design system is explicit, exhaustive prop types. Every component exposes exactly the props it accepts, with no ambiguity.
// Button.tsx
import { type ButtonHTMLAttributes, type ReactNode } from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
type ButtonProps = {
variant: ButtonVariant
size: ButtonSize
loading?: boolean
disabled?: boolean
fullWidth?: boolean
children: ReactNode
} & Pick<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'type' | 'aria-label'>
This is simple. But extend this pattern across 50 components and you eliminate entire categories of bugs:
- Typo resistance:
variant="dager"is a compile-time error, not a runtime mystery - Required props enforced: No shipping a button without a label
- Exhaustive checking: TypeScript catches every place that needs updating when a variant changes
- Self-documenting: The type definition IS the documentation for what props a component accepts
Class Variance Authority (CVA) Integration
Props define the contract. CVA maps those props to actual CSS classes. When combined, they create a system where the variants that exist in your CSS are exactly the variants that TypeScript enforces.
import { cva, type VariantProps } from 'class-variance-authority'
const buttonVariants = cva(
// Base styles — always applied
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-500',
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
},
},
compoundVariants: [
{
variant: 'ghost',
size: 'lg',
className: 'uppercase tracking-wide', // Special case for large ghost buttons
},
],
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
// Infer the props type from the CVA config
type ButtonVariants = VariantProps<typeof buttonVariants>
type ButtonProps = ButtonVariants & {
loading?: boolean
children: ReactNode
} & ComponentPropsWithoutRef<'button'>
function Button({ variant, size, loading, className, children, ...props }: ButtonProps) {
return (
<button
className={buttonVariants({ variant, size, className })}
disabled={props.disabled || loading}
{...props}
>
{loading && <Spinner className="mr-2" />}
{children}
</button>
)
}
The critical flow is: change a variant in CVA → TypeScript automatically knows about it → every usage is validated at compile time. No mismatches between the design system definition and its consumption.
Variant Validation at Build Time
CVA's VariantProps utility type extracts the variant keys from your cva definition. When you add a new variant, TypeScript will flag every component usage that does not handle it. This is especially powerful for compound variants:
// If you add: size: { xl: 'h-14 px-8 text-xl' }
// TypeScript immediately flags every Button without a size prop handler
Generating Documentation from Types
Manual documentation rots. The moment you update a component but forget to update the docs page, you have a gap between what the design system says and what it does.
Type-safe design systems solve this by generating documentation directly from TypeScript types. Tools like react-docgen-typescript and ts-morph parse your component files and produce prop tables automatically.
// With react-docgen-typescript in Storybook
import { withProps } from 'react-docgen-typescript'
export default {
title: 'Components/Button',
component: Button,
parameters: {
docs: {
// Auto-generate the props table from TypeScript types
extractComponentProps: withProps,
},
},
}
The result is a prop table in Storybook that is always in sync with the actual component:
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| variant | 'primary' | 'secondary' | 'ghost' | 'danger' |
Yes | 'primary' |
Visual style variant |
| size | 'sm' | 'md' | 'lg' |
Yes | 'md' |
Button size |
| loading | boolean |
No | false |
Show loading spinner |
| disabled | boolean |
No | false |
Disabled state |
| fullWidth | boolean |
No | false |
Full width button |
| children | ReactNode |
Yes | — | Button content |
Custom JSDoc Tags
You can extend the documentation with custom JSDoc tags for richer guidance:
type ButtonProps = {
/** @default 'primary' */
variant?: ButtonVariant
/** @default 'md' */
size?: ButtonSize
/**
* When true, shows a spinner and disables interaction.
* Use for async operations like form submissions.
* @default false
*/
loading?: boolean
}
These annotations flow through to the generated documentation, giving consumers a complete reference without ever leaving their editor or the Storybook interface.
Union Types for Design Tokens
Design tokens — colors, spacing, typography, shadows — should be union types, not string literals scattered across your codebase.
// tokens/colors.ts
export type ColorToken =
| 'gray-50' | 'gray-100' | 'gray-200' | 'gray-300' | 'gray-400'
| 'gray-500' | 'gray-600' | 'gray-700' | 'gray-800' | 'gray-900'
| 'blue-50' | 'blue-100' | 'blue-200' | 'blue-300' | 'blue-400'
| 'blue-500' | 'blue-600' | 'blue-700' | 'blue-800' | 'blue-900'
| 'red-50' | 'red-100' | 'red-200' | 'red-300' | 'red-400'
| 'red-500' | 'red-600' | 'red-700' | 'red-800' | 'red-900'
export type SpacingToken = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '8' | '10' | '12' | '16' | '20' | '24'
export type ShadowToken = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
export type RadiusToken = 'none' | 'sm' | 'md' | 'lg' | 'full'
Then use them in your components and utilities:
type BoxProps = {
bg?: ColorToken
padding?: SpacingToken
shadow?: ShadowToken
rounded?: RadiusToken
}
This approach has saved us countless times. When the design team renames blue-500 to blue-600, we update the token definition and TypeScript flags every usage across every component, page, and utility function.
Generating Token Types from Design Tokens
For teams using design token files (JSON or Style Dictionary), generate the union types automatically:
// scripts/generate-tokens.ts
const tokens = require('../tokens.json')
const colorTypes = Object.keys(tokens.color)
.map(k => `'${k}'`)
.join(' | ')
const output = `export type ColorToken = ${colorTypes}\n`
fs.writeFileSync('src/tokens/colors.ts', output)
Run this in a pre-commit hook or CI step. Now your type definitions are derived from your design tokens, not maintained alongside them.
Theming with Type Safety
Type-safe theming means consumers cannot pass invalid theme keys and theme authors cannot forget to implement required variants.
// theme.ts
type ThemeMode = 'light' | 'dark'
type Theme = {
colors: Record<ColorToken, string>
spacing: Record<SpacingToken, string>
shadows: Record<ShadowToken, string>
radii: Record<RadiusToken, string>
}
const lightTheme: Theme = {
colors: {
'gray-50': '#f9fafb',
// ... every ColorToken must be implemented
},
spacing: {
'0': '0px',
// ... every SpacingToken must be implemented
},
// ...
}
const darkTheme: Theme = {
// TypeScript enforces that ALL keys from the token types are present
// If 'blue-500' is in ColorToken, it MUST be in darkTheme.colors
}
const themes: Record<ThemeMode, Theme> = {
light: lightTheme,
dark: darkTheme,
}
With this pattern, adding a new color token means:
- Add the token to the union type
- TypeScript tells you every theme object that needs updating
- TypeScript tells you every component that references the token
- You fix them all in one pass, with zero runtime testing needed
Testing Typed Components
Type-level testing is a first-class practice in a type-safe design system. Use vitest with expect-type or tsd to assert that your types behave as expected.
import { expectTypeOf } from 'vitest'
import { Button } from './Button'
describe('Button types', () => {
it('accepts valid variants', () => {
expectTypeOf<ButtonProps>()
.toHaveProperty('variant')
.toEqualTypeOf<'primary' | 'secondary' | 'ghost' | 'danger'>()
})
it('marks variant as required', () => {
expectTypeOf<ButtonProps>()
.toHaveProperty('variant')
.toBeRequired()
})
it('excludes invalid props', () => {
// onClick should be typed as the native button handler
expectTypeOf<ButtonProps>()
.toHaveProperty('onClick')
.toBeFunction()
})
})
These tests catch regressions when someone accidentally loosens a type or changes a variant name without updating all consumers. They run in CI alongside your unit tests and take milliseconds.
Refactoring Case Studies
Case Study 1: Renaming a Variant
We maintained a Button component with a variant: 'default' | 'outline' | 'cta'. The design team decided to rename cta to brand and add a new ghost variant.
Without type safety: grep for variant="cta" across the codebase, manually update each file, hope you caught everything, test the entire application, fix the ones you missed, repeat.
With type safety: Update the CVA definition, rename cta to brand, add ghost. TypeScript shows 23 compilation errors across 14 files. Fix each one. Takes 12 minutes. Ship with confidence.
// Before
variant: { default: '...', outline: '...', cta: 'btn-cta' }
// After
variant: { default: '...', outline: '...', brand: 'btn-brand', ghost: 'btn-ghost' }
Every usage of variant="cta" across the entire codebase — components, pages, tests, Storybook stories — lights up as an error. You fix them all before you even run the dev server.
Case Study 2: Adding a New Component Size
A design system started with Button sizes sm and md. After user research, lg and xl were needed.
The CVA update was trivial, but the type system revealed that five different icon components, three input components, and two badge components all needed to be updated to support the new sizes. Without types, each of these would have been discovered individually during manual testing or, worse, in production.
Case Study 3: Merging Two Components
When merging a LinkButton component into a unified Button with a as prop, the type-safe approach made the refactoring manageable:
type ButtonProps = {
as?: 'button' | 'a'
variant: ButtonVariant
size: ButtonSize
// When as='a', require href
} & (
| { as?: 'button'; type?: 'button' | 'submit' | 'reset' }
| { as: 'a'; href: string; target?: '_blank' }
)
The discriminated union ensures that href is required when as="a" and disallowed when as="button". This kind of conditional type safety is impossible without a type-first approach.
Real-World Impact
We measured the impact of type-safe design systems across three WEIRDSOFT projects:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Component-related bugs per sprint | 8-12 | 1-3 | 75% reduction |
| Time to add a new variant to an existing component | 2-4 hours | 20-40 minutes | 80% reduction |
| Time to update a global token change | 4-8 hours | 30-60 minutes | 85% reduction |
| Developer onboarding time to contribute to design system | 2-3 days | 4-6 hours | 80% reduction |
| Manual QA cycles before release | 3-4 | 1 | 70% reduction |
These gains compound. Every variant, every component, every token added to a type-safe design system reduces the friction of future changes. The system becomes an asset that makes the team faster over time, rather than a liability that slows them down.
Getting Started
If you are adopting type safety in an existing design system, start with the most-used components. Button, Input, Select, and Modal typically account for 80% of design system usage. Type them first, add CVA, generate documentation, and measure the impact.
The principles are simple:
- Every prop is typed, not just some
- Variants are defined in one place and inferred everywhere
- Documentation is generated, not maintained
- Theming is enforced by the type system
- Type tests run in CI alongside unit tests
Start typed. Stay typed. Your future self — and every developer who inherits your codebase — will thank you.