Skip to main content

Building Type-Safe Design System Components

A type-safe design system codifies your brand's visual language into React components with TypeScript types that prevent invalid styling combinations at compile time. By combining template literal types, conditional types, branded types, and mapped types, you can create a component library where variant combinations are exhaustively checked, theme tokens are strongly typed, and component props form a self-documenting API that guides developers toward correct usage automatically.

Defining Design Tokens as Types

Start by establishing design tokens (colors, spacing, typography) as TypeScript types. These become the source of truth for your system:

// Define color tokens
export const colorTokens = {
primary: "#0066cc",
secondary: "#666666",
success: "#00aa00",
error: "#dd0000",
warning: "#ff9900",
} as const;

export type ColorToken = keyof typeof colorTokens;

// Define spacing scale
export const spacingTokens = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "32px",
xl: "64px",
} as const;

export type SpacingToken = keyof typeof spacingTokens;

// Define typography scales
export const typographyTokens = {
h1: { size: "32px", weight: "bold" },
h2: { size: "24px", weight: "bold" },
body: { size: "16px", weight: "normal" },
caption: { size: "12px", weight: "normal" },
} as const;

export type TypographyToken = keyof typeof typographyTokens;

Now component props reference these tokens, ensuring only valid values are passed:

interface CardProps {
padding?: SpacingToken;
borderColor?: ColorToken;
children: React.ReactNode;
}

export const Card = ({
padding = "md",
borderColor = "primary",
children,
}: CardProps) => {
const paddingValue = spacingTokens[padding];
const borderColorValue = colorTokens[borderColor];

return (
<div
style={{
padding: paddingValue,
borderColor: borderColorValue,
border: "1px solid",
}}
>
{children}
</div>
);
};

// ✓ Valid: uses defined token
<Card padding="lg" borderColor="success">
Content
</Card>;

// ✗ Error: "huge" is not a SpacingToken
<Card padding="huge">
Content
</Card>;

Variant Combinations with Conditional Types

Design systems often have rules about which variants can combine. A Button might not allow the "danger" variant with "outline" style. Use conditional types to enforce these rules:

type ButtonStyle = "solid" | "outline" | "ghost";
type ButtonColor = "primary" | "secondary" | "danger" | "success";

// Conditional type: danger color requires solid style
type ButtonStyleForColor<C extends ButtonColor> = C extends "danger"
? "solid" // danger only works with solid
: ButtonStyle; // other colors can use any style

interface ButtonProps<C extends ButtonColor = "primary"> {
color: C;
style?: ButtonStyleForColor<C>;
children: React.ReactNode;
onClick?: () => void;
}

export const Button = <C extends ButtonColor = "primary">({
color,
style = "solid",
children,
onClick,
}: ButtonProps<C>) => {
const className = `btn btn-${color} btn-${style}`;
return (
<button className={className} onClick={onClick}>
{children}
</button>
);
};

// ✓ Valid: danger with solid
<Button color="danger" style="solid">
Delete
</Button>;

// ✓ Valid: primary with outline (primary allows any style)
<Button color="primary" style="outline">
View
</Button>;

// ✗ Error: danger does not allow outline style
<Button color="danger" style="outline">
Delete
</Button>;

Responsive Props with Template Literals

Create a responsive component system where breakpoint-specific props are enforced via template literal types:

type Breakpoint = "mobile" | "tablet" | "desktop";
type ResponsiveKey = `${Breakpoint}:${string}`;

// Template literal type for responsive spacing props
type ResponsiveSpacingProp = `${Breakpoint}:${SpacingToken}`;

interface ResponsiveBoxProps {
padding?: ResponsiveSpacingProp | SpacingToken;
margin?: ResponsiveSpacingProp | SpacingToken;
children: React.ReactNode;
}

export const ResponsiveBox = ({
padding,
margin,
children,
}: ResponsiveBoxProps) => {
// At runtime, parse responsive props and apply media queries
return <div style={{ padding, margin }}>{children}</div>;
};

// ✓ Valid: specific token or responsive tokens
<ResponsiveBox padding="md" margin="mobile:sm desktop:lg">
Content
</ResponsiveBox>;

// ✗ Error: "invalid:sm" doesn't match pattern
<ResponsiveBox padding="invalid:sm">
Content
</ResponsiveBox>;

Composition Pattern with Higher-Order Types

Create a composition API for combining design system components safely:

// A composable component interface
interface ComposableComponent {
padding?: SpacingToken;
margin?: SpacingToken;
backgroundColor?: ColorToken;
}

// Extract these props from any component
type ExtractDesignProps<T> = T extends ComposableComponent
? Pick<T, "padding" | "margin" | "backgroundColor">
: never;

interface LayoutProps extends ComposableComponent {
direction: "row" | "column";
gap: SpacingToken;
children: React.ReactNode;
}

export const Layout = ({
direction,
gap,
padding = "md",
margin = "xs",
backgroundColor,
children,
}: LayoutProps) => (
<div
style={{
display: "flex",
flexDirection: direction,
gap: spacingTokens[gap],
padding: spacingTokens[padding],
margin: spacingTokens[margin],
backgroundColor: backgroundColor ? colorTokens[backgroundColor] : undefined,
}}
>
{children}
</div>
);

Design System Patterns Table

PatternPurposeExample
Token objectsSource of truth for valuescolorTokens, spacingTokens
keyof typeofType from token objecttype ColorToken = keyof typeof colors
Conditional typesEnforce variant rulesButtonStyle<Color> adjusts based on color
Template literalsResponsive/variant naming`${Breakpoint}:${Token}`
Mapped typesExtract component propsPick<Props, DynamicKeys>

Key Takeaways

  • Design tokens (colors, spacing, typography) are the foundation; define them as TypeScript const objects and extract types with keyof typeof.
  • Conditional types enforce variant combination rules, ensuring invalid component states are impossible.
  • Template literal types enable exhaustive variant naming (e.g., "primary-lg", "mobile:sm") that catch typos.
  • Responsive props use template literals to enforce valid breakpoint prefixes and token suffixes.
  • A type-safe design system guides developers toward correct usage without runtime validation, improving developer experience and reducing bugs.

Frequently Asked Questions

How do I handle light/dark mode with typed tokens?

Create separate token objects for each theme: lightColorTokens and darkColorTokens. Then use a context or CSS variables to switch between them at runtime. The TypeScript types remain the same regardless of theme.

Can I generate design tokens from a design tool?

Yes. Tools like Figma and Storybook support token export to JSON. You can then generate TypeScript types from the JSON: pnpm install @cobalt-ui/cli && cobalt build converts design tokens to code.

What if my design system grows to hundreds of tokens?

Organize tokens into namespaced objects: colorTokens.brand, colorTokens.semantic, etc. Then compose types: type ColorToken = keyof typeof colorTokens.brand | keyof typeof colorTokens.semantic.

How do I test variant combinations?

Use TypeScript's type system directly. Write test files that declare props and let the compiler verify they type-check. Alternatively, use tools like tsd to write type assertion tests.

Further Reading