Building a Design System from Scratch with TypeScript
A comprehensive guide to architecting and building a production-ready design system using TypeScript, covering project structure, component patterns, and type safety strategies.
Every engineering team eventually arrives at the same crossroads: the UI codebase has become a patchwork of inconsistent components, duplicated styles, and fragile patterns. The solution is a design system — a single source of truth for your organization's UI. But building one from scratch is a significant architectural decision that demands careful planning, especially when TypeScript is your foundation. This article walks through the entire journey, from initial project scaffolding to shipping your first set of type-safe components.
Why TypeScript Is the Right Foundation
Design systems are contracts. They promise consumers a stable, predictable API. TypeScript enforces that contract at compile time, catching breaking changes before they reach production. When a component's prop interface changes, every consumer gets immediate feedback. When a design token is renamed, the compiler flags every stale reference. This is not just a nice-to-have — it is the backbone of a trustworthy design system.
Consider the difference between a loosely typed Button and one built with TypeScript:
// Without TypeScript — consumers guess at valid props
function Button({ variant, size, children, ...props }) {
// What values can variant take? Is size a string or number?
return <button {...props}>{children}</button>;
}
// With TypeScript — the API is self-documenting
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
children: React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={cn(buttonStyles({ variant, size }))}
{...props}
>
{isLoading && <Spinner size={size} />}
{!isLoading && leftIcon}
<span>{children}</span>
{!isLoading && rightIcon}
</button>
);
}
);
Button.displayName = 'Button';The TypeScript version tells consumers exactly what they can pass, provides intelligent autocompletion, and prevents invalid combinations at compile time. When you multiply this across dozens or hundreds of components, the cumulative developer experience improvement is enormous.
Beyond props, TypeScript shines in enforcing design token usage. Instead of allowing arbitrary color strings, you can constrain values to your token set:
type ColorToken = keyof typeof colorTokens;
interface TextProps {
color?: ColorToken; // only valid tokens are accepted
size?: TypographyScale;
weight?: FontWeight;
}This guarantees that every color used in your system is part of the approved palette, preventing one-off values from creeping into the codebase.
Project Structure and Monorepo Setup
A well-structured design system project scales gracefully. We recommend a monorepo approach using a tool like Turborepo or Nx. This lets you separate concerns into discrete packages while maintaining a single development workflow.
messier/
├── packages/
│ ├── tokens/ # Design tokens (colors, spacing, typography)
│ │ ├── src/
│ │ │ ├── colors.ts
│ │ │ ├── spacing.ts
│ │ │ ├── typography.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── core/ # Core component library
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── Button/
│ │ │ │ │ ├── Button.tsx
│ │ │ │ │ ├── Button.styles.ts
│ │ │ │ │ ├── Button.test.tsx
│ │ │ │ │ ├── Button.stories.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ └── index.ts
│ │ │ ├── hooks/
│ │ │ ├── utils/
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── icons/ # Icon library
│ │ └── ...
│ └── themes/ # Theme presets
│ └── ...
├── apps/
│ ├── docs/ # Documentation site
│ └── playground/ # Development sandbox
├── turbo.json
├── tsconfig.base.json
└── package.json
Each package has its own tsconfig.json that extends a shared base configuration. This is critical for maintaining consistent compiler settings:
// tsconfig.base.json
{
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}The declaration and declarationMap options are essential — they generate .d.ts files that give consumers full type information, and source maps that let them jump directly to your component source code.
Each component follows a co-located file convention. The Button/ directory contains everything related to that component: implementation, styles, tests, stories, and a barrel export. This makes components easy to find, easy to maintain, and easy to move if the architecture evolves.
Foundational Primitives and Composition Patterns
Before building complex components, establish a layer of foundational primitives. These are the low-level building blocks that enforce your design tokens and provide a consistent styling API.
A Box primitive, for example, maps design tokens to CSS properties:
import { tokens } from '@messier/tokens';
type SpacingToken = keyof typeof tokens.spacing;
type ColorToken = keyof typeof tokens.colors;
type RadiusToken = keyof typeof tokens.radii;
interface BoxProps extends React.HTMLAttributes<HTMLDivElement> {
as?: React.ElementType;
padding?: SpacingToken;
paddingX?: SpacingToken;
paddingY?: SpacingToken;
margin?: SpacingToken;
bg?: ColorToken;
borderRadius?: RadiusToken;
display?: 'flex' | 'grid' | 'block' | 'inline' | 'inline-flex' | 'none';
children?: React.ReactNode;
}
const Box = React.forwardRef<HTMLDivElement, BoxProps>(
({ as: Component = 'div', padding, paddingX, paddingY, margin, bg, borderRadius, display, style, ...props }, ref) => {
const resolvedStyle: React.CSSProperties = {
padding: padding ? tokens.spacing[padding] : undefined,
paddingLeft: paddingX ? tokens.spacing[paddingX] : undefined,
paddingRight: paddingX ? tokens.spacing[paddingX] : undefined,
paddingTop: paddingY ? tokens.spacing[paddingY] : undefined,
paddingBottom: paddingY ? tokens.spacing[paddingY] : undefined,
margin: margin ? tokens.spacing[margin] : undefined,
backgroundColor: bg ? tokens.colors[bg] : undefined,
borderRadius: borderRadius ? tokens.radii[borderRadius] : undefined,
display,
...style,
};
return <Component ref={ref} style={resolvedStyle} {...props} />;
}
);With Box in place, higher-level layout components compose on top of it:
interface StackProps extends Omit<BoxProps, 'display'> {
direction?: 'row' | 'column';
gap?: SpacingToken;
align?: React.CSSProperties['alignItems'];
justify?: React.CSSProperties['justifyContent'];
wrap?: boolean;
}
const Stack = React.forwardRef<HTMLDivElement, StackProps>(
({ direction = 'column', gap, align, justify, wrap, style, ...props }, ref) => {
return (
<Box
ref={ref}
display="flex"
style={{
flexDirection: direction,
gap: gap ? tokens.spacing[gap] : undefined,
alignItems: align,
justifyContent: justify,
flexWrap: wrap ? 'wrap' : undefined,
...style,
}}
{...props}
/>
);
}
);This layered composition approach means that every component in the system inherits the same token-constrained styling API. Changes to a token propagate everywhere automatically.
The Build Pipeline
Shipping a design system means shipping compiled JavaScript, type declarations, and CSS. A robust build pipeline handles all three. We use tsup (built on esbuild) for its speed and simplicity:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
treeshake: true,
splitting: true,
minify: false, // let consumers minify
});Key decisions here: we output both ESM and CJS to support all consumer environments. React is externalized because consumers will provide their own copy. Tree-shaking and code-splitting ensure that consumers only pay for the components they import.
The package.json must correctly declare entry points for modern bundlers:
{
"name": "@messier/core",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./styles": "./dist/styles.css"
},
"files": ["dist"],
"sideEffects": ["*.css"],
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
}The exports field is the modern standard for package entry points. The sideEffects field tells bundlers that CSS imports have side effects and should not be tree-shaken away.
Establishing Governance and Contribution Patterns
A design system is only as strong as the process around it. Without governance, the component library becomes another dumping ground for one-off solutions. Establish clear criteria for what enters the system.
A component earns its place when it meets the "rule of three": it has been needed in at least three different contexts. Before that threshold, it lives in the consuming application. This prevents premature abstraction and keeps the system lean.
Every new component should go through a lightweight RFC process:
- Proposal — describe the component, its use cases, and its proposed API (as a TypeScript interface).
- Review — the design system team and at least two consuming teams review the proposal.
- Implementation — build the component following established patterns.
- Documentation — write usage guidelines, prop documentation, and at least three examples.
- Release — publish as part of the next minor version.
Automate what you can. A component generator script ensures consistency:
// scripts/create-component.ts
import fs from 'fs';
import path from 'path';
const componentName = process.argv[2];
if (!componentName) {
console.error('Usage: tsx scripts/create-component.ts ComponentName');
process.exit(1);
}
const componentDir = path.join('packages/core/src/components', componentName);
const files = {
[`${componentName}.tsx`]: `import React from 'react';
export interface ${componentName}Props {
children?: React.ReactNode;
}
export const ${componentName} = React.forwardRef<HTMLDivElement, ${componentName}Props>(
({ children, ...props }, ref) => {
return (
<div ref={ref} {...props}>
{children}
</div>
);
}
);
${componentName}.displayName = '${componentName}';
`,
[`${componentName}.test.tsx`]: `import { render, screen } from '@testing-library/react';
import { ${componentName} } from './${componentName}';
describe('${componentName}', () => {
it('renders children', () => {
render(<${componentName}>Hello</${componentName}>);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
});
`,
['index.ts']: `export { ${componentName} } from './${componentName}';
export type { ${componentName}Props } from './${componentName}';
`,
};
fs.mkdirSync(componentDir, { recursive: true });
for (const [filename, content] of Object.entries(files)) {
fs.writeFileSync(path.join(componentDir, filename), content);
}
console.log(\`Created component: \${componentName}\`);Conclusion
Building a design system from scratch with TypeScript is a long-term investment that pays compounding returns. The type safety catches errors early, the structured project layout scales with your team, foundational primitives enforce consistency at the lowest level, and a clean build pipeline ensures reliable delivery. Start small, govern wisely, and let the system grow organically from real needs rather than speculative abstractions. The best design systems are not the most feature-rich — they are the most trusted.
Related Articles
Versioning and Publishing Component Libraries
A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.
Measuring the ROI of a Design System
A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.
Documentation That Developers Actually Read
How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.