Skip to main content

Practical Design System with TypeScript

Building a practical design system with TypeScript combines all advanced patterns—generic constraints, conditional types, mapped types, branded types, and template literals—into a cohesive, production-grade system. This article walks through the complete architecture of a real design system: from token definition and component implementation to composition patterns and theme support, showing how to enforce correctness, enable IDE autocomplete, and make maintenance effortless as your design system scales across teams and projects.

System Architecture Overview

A production design system consists of four layers: tokens (the primitive values), components (the UI building blocks), compositions (multi-component layouts), and theme support (adaptation to different contexts). Each layer builds on TypeScript patterns to ensure correctness:

// Layer 1: Design tokens (source of truth)
export const tokens = {
colors: {
primary: "#0066cc",
secondary: "#666666",
neutral: {
0: "#ffffff",
50: "#f5f5f5",
100: "#eeeeee",
900: "#1a1a1a",
},
},
spacing: {
xs: "4px",
sm: "8px",
md: "16px",
lg: "32px",
},
typography: {
h1: { fontSize: "32px", fontWeight: "bold", lineHeight: 1.2 },
body: { fontSize: "16px", fontWeight: "normal", lineHeight: 1.5 },
},
} as const;

// Extract types from tokens
export type ColorToken = keyof typeof tokens.colors;
export type SpacingToken = keyof typeof tokens.spacing;
export type TypographyToken = keyof typeof tokens.typography;

Component Layer: Type-Safe Variants

Layer 2 implements core components with strict variant typing using conditional types and template literals:

// Button component with exhaustive variant combinations
type ButtonColor = "primary" | "secondary" | "neutral";
type ButtonSize = "small" | "medium" | "large";
type ButtonVariant = "solid" | "outline" | "ghost";

// Enforce rules: outline variant only available for primary and secondary
type ValidButtonVariant<C extends ButtonColor> = C extends "neutral"
? "solid" | "ghost"
: ButtonVariant;

interface BaseButtonProps {
color?: ButtonColor;
size?: ButtonSize;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}

interface SolidButtonProps
extends BaseButtonProps {
variant?: "solid";
}

interface OutlineButtonProps
extends Omit<BaseButtonProps, "color"> {
variant: "outline";
color: Exclude<ButtonColor, "neutral">;
}

interface GhostButtonProps
extends BaseButtonProps {
variant: "ghost";
}

type ButtonProps = SolidButtonProps | OutlineButtonProps | GhostButtonProps;

export const Button: React.FC<ButtonProps> = ({
color = "primary",
size = "medium",
variant = "solid",
disabled,
children,
onClick,
}) => {
const className = `btn btn-${color} btn-${size} btn-${variant}`;
return (
<button className={className} disabled={disabled} onClick={onClick}>
{children}
</button>
);
};

// ✓ Valid: outline with primary color
<Button variant="outline" color="primary">
Edit
</Button>;

// ✗ Error: outline not allowed with neutral color
<Button variant="outline" color="neutral">
Invalid
</Button>;

Composition Layer: Building Complex Layouts

Layer 3 combines components into reusable compositions using mapped types and generic constraints:

// A composable card component
interface CardProps {
title?: string;
children: React.ReactNode;
padding?: SpacingToken;
backgroundColor?: ColorToken;
}

export const Card: React.FC<CardProps> = ({
title,
children,
padding = "md",
backgroundColor = "neutral",
}) => (
<div
className="card"
style={{
padding: tokens.spacing[padding],
backgroundColor:
backgroundColor === "neutral"
? tokens.colors.neutral[0]
: tokens.colors[backgroundColor as Exclude<ColorToken, "neutral">],
}}
>
{title && <h2>{title}</h2>}
{children}
</div>
);

// A form layout that composes inputs and buttons
interface FormLayoutProps {
title: string;
onSubmit: (data: Record<string, any>) => void;
children: React.ReactNode;
submitButtonText?: string;
submitButtonColor?: Exclude<ButtonColor, "neutral">;
}

export const FormLayout: React.FC<FormLayoutProps> = ({
title,
onSubmit,
children,
submitButtonText = "Submit",
submitButtonColor = "primary",
}) => (
<Card title={title} padding="lg" backgroundColor="neutral">
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({});
}}
>
{children}
<Button
variant="solid"
color={submitButtonColor}
size="large"
>
{submitButtonText}
</Button>
</form>
</Card>
);

Theme Support with Module Augmentation

Layer 4 adds theme support using module augmentation and conditional types to allow different color schemes:

// Define theme interface
interface Theme {
colors: typeof tokens.colors;
spacing: typeof tokens.spacing;
typography: typeof tokens.typography;
}

// Theme context
interface ThemeContextValue {
theme: "light" | "dark";
colors: Theme["colors"];
}

const ThemeContext = React.createContext<ThemeContextValue | null>(null);

export const useTheme = () => {
const ctx = React.useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
};

// Theme provider that switches between light and dark
interface ThemeProviderProps {
theme?: "light" | "dark";
children: React.ReactNode;
}

export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme = "light",
children,
}) => {
const colors =
theme === "dark"
? { ...tokens.colors, primary: "#0088ff" } // Adjusted for dark mode
: tokens.colors;

const value: ThemeContextValue = {
theme,
colors,
};

return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};

// Usage in components
const ThemedButton: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { colors } = useTheme();
return (
<button style={{ backgroundColor: colors.primary }}>
{children}
</button>
);
};

Design System Package Structure

Organize the design system as a cohesive package:

design-system/
├── tokens.ts # Export all token types and values
├── components/
│ ├── Button.tsx
│ ├── Card.tsx
│ ├── Input.tsx
│ └── index.ts # Export all components
├── compositions/
│ ├── FormLayout.tsx
│ ├── Modal.tsx
│ └── index.ts
├── theme/
│ ├── ThemeProvider.tsx
│ ├── useTheme.ts
│ └── index.ts
├── types/
│ ├── colors.ts
│ ├── spacing.ts
│ └── index.ts
└── index.ts # Main export

Export everything from a single entry point:

// design-system/index.ts
export * from "./tokens";
export * from "./components";
export * from "./compositions";
export * from "./theme";
export type * from "./types";

Integration Example: Building an App

Here's how an application uses the design system:

import {
Button,
Card,
FormLayout,
ThemeProvider,
useTheme,
type ButtonColor,
} from "@company/design-system";

function App() {
return (
<ThemeProvider theme="light">
<MainPage />
</ThemeProvider>
);
}

function MainPage() {
const { theme } = useTheme();

return (
<FormLayout
title="User Registration"
submitButtonColor="primary"
onSubmit={(data) => console.log(data)}
>
<Card title="Account Information" padding="md">
<p>Fill in your details below</p>
</Card>
</FormLayout>
);
}

Design System Benefits Table

BenefitHow TypeScript HelpsExample
Type safetyConditional types prevent invalid combinationsOutline only with primary color
DRYToken types are derived from valuesColorToken = keyof typeof colors
RefactoringChanges propagate automaticallyRename token, all usages update
DiscoverabilityIDE autocomplete on tokens and propsAutocomplete in component attributes
MaintainabilitySingle source of truth for tokensOne file to update colors globally

Key Takeaways

  • A production design system has four layers: tokens (primitives), components (building blocks), compositions (layouts), and theme support (adaptation).
  • Tokens are defined as const objects and types are extracted with keyof typeof to avoid duplication.
  • Component variants use conditional types and discriminated unions to enforce valid combinations.
  • Theme providers use context and module augmentation to enable flexible theming across the system.
  • A well-structured design system speeds development, reduces bugs, and makes refactoring safe for entire teams.

Frequently Asked Questions

How do I versioning a design system package?

Use semantic versioning: major for breaking changes (prop removals), minor for additions, patch for fixes. Document changes in a CHANGELOG. Consider whether prop additions require major versions (if conditional types change behavior) or are additive (minor).

Can I support multiple theme variations (light, dark, high contrast)?

Yes. Define theme objects for each variant and pass them through context. Use TypeScript's Record<"light" | "dark" | "highContrast", Theme> to ensure all variants are implemented exhaustively.

How do I test a design system?

Test components in isolation with Storybook, test variant combinations with TypeScript's type system (compile-time tests), and use snapshot tests for visual regression. Tools like Chromatic automate visual testing.

Should I use CSS-in-JS or regular CSS with the design system?

Either works. CSS-in-JS (emotion, styled-components) gives you TypeScript access to tokens at build time. Regular CSS works well too; just ensure tokens are consumed consistently (CSS variables, generated CSS, etc.).

Further Reading