DEV Community

Cover image for TypeScript for Robust Design Systems: Patterns and Best Practices
Aarav Joshi
Aarav Joshi

Posted on

TypeScript for Robust Design Systems: Patterns and Best Practices

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Building effective design systems relies on strong typing for consistency and developer experience. TypeScript elevates this process by providing static type checking that catches errors early in the development cycle.

I've spent years working with TypeScript-based design systems, and I've found several techniques that dramatically improve both code quality and team productivity.

Component Typing Architecture

The foundation of any TypeScript design system starts with well-defined component interfaces. When I create component props, I ensure they clearly communicate both required and optional values:

interface ButtonProps {
  variant: 'primary' | 'secondary' | 'ghost';
  size?: 'small' | 'medium' | 'large';
  isDisabled?: boolean;
  onClick?: () => void;
  children: React.ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

For components with different modes of operation, I use discriminated unions to create variant-specific prop sets:

type BaseInputProps = {
  label: string;
  name: string;
  required?: boolean;
};

type TextInputProps = BaseInputProps & {
  type: 'text' | 'email' | 'password';
  maxLength?: number;
};

type NumberInputProps = BaseInputProps & {
  type: 'number';
  min?: number;
  max?: number;
};

type InputProps = TextInputProps | NumberInputProps;
Enter fullscreen mode Exit fullscreen mode

For flexible components that need to handle different data types, I leverage generics:

interface SelectProps<T> {
  options: Array<{value: T, label: string}>;
  value: T;
  onChange: (value: T) => void;
}

// Usage
<Select<string> 
  options={[{value: 'opt1', label: 'Option 1'}]} 
  value="opt1"
  onChange={(val) => console.log(val)} // val is typed as string
/>
Enter fullscreen mode Exit fullscreen mode

Prop Validation Patterns

While TypeScript offers compile-time checks, runtime validation adds another layer of protection:

import * as z from 'zod';

const ButtonSchema = z.object({
  variant: z.enum(['primary', 'secondary', 'ghost']),
  size: z.enum(['small', 'medium', 'large']).optional().default('medium'),
  isDisabled: z.boolean().optional().default(false),
  onClick: z.function().optional(),
  children: z.any()
});

type ButtonProps = z.infer<typeof ButtonSchema>;

export const Button = (props: ButtonProps) => {
  // Parse props at runtime
  const validatedProps = ButtonSchema.parse(props);

  // Now use validatedProps safely
  const { variant, size, isDisabled, onClick, children } = validatedProps;

  // Component implementation
};
Enter fullscreen mode Exit fullscreen mode

I've found that combining static and runtime validation creates the most reliable components, especially when receiving props from external sources.

Theme Type Safety

A design system's theme should be strongly typed to prevent inconsistencies:

type ColorToken = 'primary100' | 'primary200' | 'primary300' | 'neutral100' | 'neutral200' | 'neutral300';
type SpacingToken = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type FontSizeToken = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';

interface Theme {
  colors: Record<ColorToken, string>;
  spacing: Record<SpacingToken, string>;
  fontSizes: Record<FontSizeToken, string>;
}

// Theme implementation
export const theme: Theme = {
  colors: {
    primary100: '#e6f7ff',
    primary200: '#91caff',
    primary300: '#1677ff',
    neutral100: '#ffffff',
    neutral200: '#f5f5f5',
    neutral300: '#000000',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '16px',
    lg: '24px',
    xl: '32px',
  },
  fontSizes: {
    xs: '12px',
    sm: '14px',
    md: '16px',
    lg: '18px',
    xl: '20px',
    xxl: '24px',
  }
};
Enter fullscreen mode Exit fullscreen mode

I create utility types to access these tokens with autocomplete:

type ColorProps = {
  color?: ColorToken;
  backgroundColor?: ColorToken;
};

const getColor = (token: ColorToken) => theme.colors[token];
const getSpacing = (token: SpacingToken) => theme.spacing[token];
Enter fullscreen mode Exit fullscreen mode

Strict Component Composition

Type-safe component composition is essential for building complex UIs. I use higher-order components that preserve the original component's type information:

function withTheme<T extends object>(
  Component: React.ComponentType<T & ThemeProps>
): React.FC<Omit<T, keyof ThemeProps>> {
  return (props) => {
    const theme = useTheme();
    return <Component {...props as T} theme={theme} />;
  };
}

// The original prop types are preserved
const ThemedButton = withTheme(Button);
Enter fullscreen mode Exit fullscreen mode

I also create composition utilities that ensure children components match expected types:

interface TabProps {
  label: string;
  value: string;
  children: React.ReactNode;
}

interface TabsProps {
  value: string;
  onChange: (value: string) => void;
  children: React.ReactElement<TabProps>[];
}

const Tabs: React.FC<TabsProps> = ({ children, value, onChange }) => {
  // Implementation
};

const Tab: React.FC<TabProps> = ({ children }) => {
  // Implementation
};

// Usage with type checking
<Tabs value="tab1" onChange={(val) => console.log(val)}>
  <Tab label="Tab 1" value="tab1">Content 1</Tab>
  <Tab label="Tab 2" value="tab2">Content 2</Tab>
  {/* TypeScript error: Type '{ children: string; }' is not assignable to type 'ReactElement<TabProps>' */}
  {"This would cause a type error"}
</Tabs>
Enter fullscreen mode Exit fullscreen mode

Pattern Documentation

Generating documentation directly from TypeScript definitions ensures it stays in sync with the code:

/**
 * Button component for user interaction
 * @example
 * <Button variant="primary" onClick={() => alert('Clicked!')}>Click Me</Button>
 */
interface ButtonProps {
  /** The visual style of the button */
  variant: 'primary' | 'secondary' | 'ghost';
  /** Optional button size - defaults to 'medium' if not provided */
  size?: 'small' | 'medium' | 'large';
  /** Whether the button is disabled */
  isDisabled?: boolean;
  /** Click handler function */
  onClick?: () => void;
  /** Button content */
  children: React.ReactNode;
}
Enter fullscreen mode Exit fullscreen mode

I use tools like TypeDoc or Storybook with the Docs addon to automatically extract these comments into living documentation.

Style Extraction

For CSS-in-JS solutions, I create type-safe style utilities:

import { css } from '@emotion/react';

type StyleProps = {
  color?: ColorToken;
  margin?: SpacingToken | SpacingToken[];
  padding?: SpacingToken | SpacingToken[];
  fontSize?: FontSizeToken;
};

// Helper that converts tokens to CSS
const createStyles = (props: StyleProps) => css`
  ${props.color && `color: ${getColor(props.color)};`}
  ${props.fontSize && `font-size: ${theme.fontSizes[props.fontSize]};`}

  ${props.margin && Array.isArray(props.margin)
    ? `margin: ${props.margin.map(m => getSpacing(m)).join(' ')};`
    : props.margin && `margin: ${getSpacing(props.margin)};`
  }

  ${props.padding && Array.isArray(props.padding)
    ? `padding: ${props.padding.map(p => getSpacing(p)).join(' ')};`
    : props.padding && `padding: ${getSpacing(props.padding)};`
  }
`;

// Usage
const styledComponent = css`
  ${createStyles({
    color: 'primary300',
    margin: ['md', 'lg'],
    padding: 'sm',
    fontSize: 'md'
  })}
`;
Enter fullscreen mode Exit fullscreen mode

Testing Type Compliance

To ensure components meet their type contracts at runtime, I implement specialized testing utilities:

import { render } from '@testing-library/react';

function testPropTypes<P>(
  Component: React.ComponentType<P>,
  validProps: P,
  propVariations: Partial<Record<keyof P, any[]>>
) {
  // Test with valid props
  test('renders with valid props', () => {
    const { container } = render(<Component {...validProps} />);
    expect(container).toBeTruthy();
  });

  // Test with each prop variation
  Object.entries(propVariations).forEach(([propName, values]) => {
    values.forEach(value => {
      test(`renders with ${String(propName)}=${String(value)}`, () => {
        const props = { ...validProps, [propName]: value };
        const { container } = render(<Component {...props} />);
        expect(container).toBeTruthy();
      });
    });
  });
}

// Usage
testPropTypes(
  Button,
  { variant: 'primary', children: 'Click me' },
  {
    variant: ['secondary', 'ghost'],
    size: ['small', 'medium', 'large'],
    isDisabled: [true, false]
  }
);
Enter fullscreen mode Exit fullscreen mode

I've also created a comprehensive example that combines many of these techniques:

// Advanced component with theme integration, generics, and discriminated unions
import React from 'react';

// Theme types
interface ThemeTokens {
  colors: {
    primary: string;
    secondary: string;
    warning: string;
    error: string;
    success: string;
    info: string;
    grey50: string;
    grey100: string;
    grey500: string;
    grey900: string;
  };
  spacing: {
    xs: string;
    sm: string;
    md: string;
    lg: string;
    xl: string;
    xxl: string;
  };
  fontSizes: {
    xs: string;
    sm: string;
    md: string;
    lg: string;
    xl: string;
  };
  borderRadius: {
    sm: string;
    md: string;
    lg: string;
    pill: string;
  };
}

// Card component with flexible content type
type CardBaseProps = {
  title: string;
  subtitle?: string;
  elevation?: 0 | 1 | 2 | 3;
  fullWidth?: boolean;
  padding?: keyof ThemeTokens['spacing'];
  borderRadius?: keyof ThemeTokens['borderRadius'];
};

type StandardCardProps<T> = CardBaseProps & {
  variant: 'standard';
  data: T;
  renderContent: (data: T) => React.ReactNode;
};

type ActionCardProps<T> = CardBaseProps & {
  variant: 'action';
  data: T;
  primaryAction: {
    label: string;
    onClick: (data: T) => void;
  };
  secondaryAction?: {
    label: string;
    onClick: (data: T) => void;
  };
};

type StatusCardProps<T> = CardBaseProps & {
  variant: 'status';
  data: T;
  status: 'warning' | 'error' | 'success' | 'info';
  statusMessage: string;
};

export type CardProps<T> = StandardCardProps<T> | ActionCardProps<T> | StatusCardProps<T>;

export const Card = <T,>({
  title,
  subtitle,
  elevation = 1,
  fullWidth = false,
  padding = 'md',
  borderRadius = 'md',
  ...props
}: CardProps<T>): React.ReactElement => {
  // Theme values from context (simplified for example)
  const theme: ThemeTokens = {
    colors: {
      primary: '#1677ff',
      secondary: '#6c757d',
      warning: '#ffc107',
      error: '#dc3545',
      success: '#28a745',
      info: '#17a2b8',
      grey50: '#f8f9fa',
      grey100: '#e9ecef',
      grey500: '#adb5bd',
      grey900: '#212529',
    },
    spacing: {
      xs: '4px',
      sm: '8px',
      md: '16px',
      lg: '24px',
      xl: '32px',
      xxl: '48px',
    },
    fontSizes: {
      xs: '12px',
      sm: '14px',
      md: '16px',
      lg: '18px',
      xl: '20px',
    },
    borderRadius: {
      sm: '4px',
      md: '8px',
      lg: '16px',
      pill: '9999px',
    },
  };

  // Common card styles
  const cardStyle: React.CSSProperties = {
    backgroundColor: theme.colors.grey50,
    borderRadius: theme.borderRadius[borderRadius],
    padding: theme.spacing[padding],
    boxShadow: elevation > 0 ? `0 ${elevation * 2}px ${elevation * 4}px rgba(0, 0, 0, 0.1)` : 'none',
    width: fullWidth ? '100%' : 'auto',
    overflow: 'hidden',
  };

  // Header styles
  const headerStyle: React.CSSProperties = {
    marginBottom: theme.spacing.md,
  };

  const titleStyle: React.CSSProperties = {
    fontSize: theme.fontSizes.lg,
    margin: 0,
    color: theme.colors.grey900,
  };

  const subtitleStyle: React.CSSProperties = {
    fontSize: theme.fontSizes.sm,
    margin: `${theme.spacing.xs} 0 0 0`,
    color: theme.colors.grey500,
  };

  // Render card content based on variant
  let cardContent: React.ReactNode;
  let statusBar: React.ReactNode;

  if (props.variant === 'standard') {
    cardContent = props.renderContent(props.data);
  } else if (props.variant === 'action') {
    const { primaryAction, secondaryAction, data } = props;

    const buttonBaseStyle: React.CSSProperties = {
      padding: `${theme.spacing.sm} ${theme.spacing.md}`,
      borderRadius: theme.borderRadius.sm,
      border: 'none',
      cursor: 'pointer',
      fontSize: theme.fontSizes.sm,
      fontWeight: 'bold',
    };

    const primaryButtonStyle: React.CSSProperties = {
      ...buttonBaseStyle,
      backgroundColor: theme.colors.primary,
      color: theme.colors.grey50,
    };

    const secondaryButtonStyle: React.CSSProperties = {
      ...buttonBaseStyle,
      backgroundColor: 'transparent',
      color: theme.colors.primary,
    };

    cardContent = (
      <div style={{ display: 'flex', gap: theme.spacing.sm, justifyContent: 'flex-end' }}>
        {secondaryAction && (
          <button 
            style={secondaryButtonStyle}
            onClick={() => secondaryAction.onClick(data)}
          >
            {secondaryAction.label}
          </button>
        )}
        <button 
          style={primaryButtonStyle}
          onClick={() => primaryAction.onClick(data)}
        >
          {primaryAction.label}
        </button>
      </div>
    );
  } else if (props.variant === 'status') {
    const { status, statusMessage } = props;

    const statusColors = {
      warning: theme.colors.warning,
      error: theme.colors.error,
      success: theme.colors.success,
      info: theme.colors.info,
    };

    statusBar = (
      <div style={{
        backgroundColor: statusColors[status],
        padding: theme.spacing.sm,
        color: status === 'warning' ? theme.colors.grey900 : theme.colors.grey50,
        fontSize: theme.fontSizes.sm,
        fontWeight: 'bold',
        marginBottom: theme.spacing.md,
      }}>
        {statusMessage}
      </div>
    );
  }

  return (
    <div style={cardStyle}>
      {props.variant === 'status' && statusBar}
      <div style={headerStyle}>
        <h3 style={titleStyle}>{title}</h3>
        {subtitle && <p style={subtitleStyle}>{subtitle}</p>}
      </div>
      {cardContent}
    </div>
  );
};

// Usage examples
interface UserData {
  id: number;
  name: string;
  email: string;
}

// Standard card example
const StandardCardExample = () => {
  const userData: UserData = {
    id: 1,
    name: "John Doe",
    email: "john@example.com"
  };

  return (
    <Card<UserData>
      variant="standard"
      title="User Information"
      subtitle="Basic profile details"
      data={userData}
      elevation={2}
      renderContent={(data) => (
        <div>
          <p><strong>Name:</strong> {data.name}</p>
          <p><strong>Email:</strong> {data.email}</p>
        </div>
      )}
    />
  );
};

// Action card example
const ActionCardExample = () => {
  const userData: UserData = {
    id: 1, 
    name: "John Doe",
    email: "john@example.com"
  };

  return (
    <Card<UserData>
      variant="action"
      title="User Actions"
      subtitle="Manage user account"
      data={userData}
      elevation={1}
      primaryAction={{
        label: "Edit User",
        onClick: (data) => console.log(`Editing user ${data.id}`)
      }}
      secondaryAction={{
        label: "Delete",
        onClick: (data) => console.log(`Deleting user ${data.id}`)
      }}
    />
  );
};

// Status card example
const StatusCardExample = () => {
  const userData: UserData = {
    id: 1,
    name: "John Doe",
    email: "john@example.com"
  };

  return (
    <Card<UserData>
      variant="status"
      title="Account Status"
      data={userData}
      status="success"
      statusMessage="Account verified successfully"
      elevation={1}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

My experience has shown that investment in type safety pays dividends as design systems grow. Teams that adopt these TypeScript techniques report fewer bugs, faster development cycles, and improved developer confidence.

When implementing a design system with TypeScript, I recommend starting small and gradually building up your type complexity. Begin with basic interfaces and add advanced features like generics and conditional types as your team becomes more comfortable with the system.

The payoff comes when developers can confidently use components without constantly referring to documentation, knowing the type system will catch potential errors before they reach production.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Modern auth, access management and billing for engineers.

Modern auth, access management and billing for engineers.

Secure and monetize your product from day one – with less code.

Get a free account

Top comments (0)

Gen AI apps are built with MongoDB Atlas

Gen AI apps are built with MongoDB Atlas

MongoDB Atlas is the developer-friendly database for building, scaling, and running gen AI & LLM apps—no separate vector DB needed. Enjoy native vector search, 115+ regions, and flexible document modeling. Build AI faster, all in one place.

Start Free

👋 Kindness is contagious

Explore this insightful write-up embraced by the inclusive DEV Community. Tech enthusiasts of all skill levels can contribute insights and expand our shared knowledge.

Spreading a simple "thank you" uplifts creators—let them know your thoughts in the discussion below!

At DEV, collaborative learning fuels growth and forges stronger connections. If this piece resonated with you, a brief note of thanks goes a long way.

Okay