Learn

/

Enumerated Variants

Enumerated Variants

8 patterns

Replacing boolean prop explosion with string union enums for size, variant, color, and other visual dimensions.

Avoid
interface ButtonProps {
  children: React.ReactNode;
  isSmall?: boolean;
  isLarge?: boolean;
}

Prefer
interface ButtonProps {
  children: React.ReactNode;
  /** @default "md" */
  size?: 'sm' | 'md' | 'lg';
}

A size union replaces two booleans with a single, self-documenting prop. <Button size="sm" /> is clear, and adding "xs" later is just one more union member.

With booleans, <Button isSmall isLarge /> compiles fine but makes no sense. Unions make invalid states impossible and scale without combinatorial explosion.

MUI: Button API
Avoid
interface AlertProps {
  children: React.ReactNode;
  isError?: boolean;
  isWarning?: boolean;
  isSuccess?: boolean;
  isInfo?: boolean;
}

Prefer
interface AlertProps {
  children: React.ReactNode;
  /** @default "info" */
  severity?: 'error' | 'warning' | 'success' | 'info';
}

Four mutually exclusive booleans should always be a single union. <Alert severity="error" /> reads better than <Alert isError /> and makes it impossible to pass conflicting states.

Bonus: severity works naturally in a switch statement for styling and icon selection, and adding new variants later is trivial.

MUI: Alert API
Avoid
interface TextProps {
  children: React.ReactNode;
  alignLeft?: boolean;
  alignCenter?: boolean;
  alignRight?: boolean;
  alignJustify?: boolean;
}

Prefer
interface TextProps {
  children: React.ReactNode;
  /** @default "left" */
  align?: 'left' | 'center' | 'right' | 'justify';
}

This is a textbook case for a union. align maps directly to the CSS text-align property, making the prop name and values immediately familiar. One prop, one concept, one value.

<Text align="center" /> is concise and self-explanatory. MUI's Typography uses this exact pattern.

MUI: Typography API
Avoid
interface ChipProps {
  label: string;
  isOutlined?: boolean;
  isFilled?: boolean;
  isClickable?: boolean;
  isDeletable?: boolean;
  isPrimary?: boolean;
  isSecondary?: boolean;
}

Prefer
interface ChipProps {
  label: string;
  /** @default "filled" */
  variant?: 'filled' | 'outlined';
  /** @default "default" */
  color?: 'default' | 'primary' | 'secondary';
  onClick?: () => void;
  onDelete?: () => void;
}

Six booleans collapse into two enums plus event callbacks. variant and color are independent dimensions, so you can combine any variant with any color. And clickable/deletable are better expressed by the presence of their callbacks: if onClick is defined, the chip is clickable.

This is exactly how MUI's Chip API works.

MUI: Chip API
Avoid
interface DataViewProps<T> {
  data: T[];
  isLoading?: boolean;
  isError?: boolean;
  isEmpty?: boolean;
  isRefreshing?: boolean;
}

Prefer
interface DataViewProps<T> {
  data: T[];
  /** @default "idle" */
  status?: 'idle' | 'loading' | 'error'
    | 'empty' | 'refreshing';
}

Async state is a finite state machine. The view is always in exactly one state. A status union models this correctly and lets you exhaustively handle every case in a switch.

With booleans, isLoading && isError is a valid but meaningless combination that your component must defend against.

TkDodo: Status Checks in React Query
Avoid
interface StackProps {
  children: React.ReactNode;
  /** Gap between items in pixels. */
  gap?: number;
  /** Padding in pixels. */
  padding?: number;
}

Prefer
interface StackProps {
  children: React.ReactNode;
  /** @default "md" */
  gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
  /** @default "none" */
  padding?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}

Named spacing tokens enforce visual consistency. gap="md" constrains consumers to your design system's scale, so every component uses the same values.

Note: MUI uses numbers (gap={2}) that multiply theme.spacing, which also works because the multiplier constrains values to the scale. The key insight is the same: don't accept arbitrary pixel values. Whether you use string tokens or a numeric multiplier, the API should map to a finite set of spacing steps.

Tailwind: Customizing Spacing
Avoid
interface ButtonProps {
  children: React.ReactNode;
  icon?: React.ReactNode;
  isIconBefore?: boolean;
  isIconAfter?: boolean;
  isIconOnly?: boolean;
}

Prefer
interface ButtonProps {
  children: React.ReactNode;
  icon?: React.ReactNode;
  /** @default "start" */
  iconPosition?: 'start' | 'end';
} | {
  'aria-label': string;
  icon: React.ReactNode;
  iconPosition?: never;
}

The "icon-only" variant is fundamentally different: it requires aria-label for accessibility and has no children. That's a discriminated union, not a boolean.

For icon + text buttons, iconPosition is a clean enum. <Button icon={<Save />} iconPosition="end">Save</Button> reads naturally. Three booleans for one concept is a code smell.

MUI: Button API
Avoid
interface CardProps {
  children: React.ReactNode;
  isElevated?: boolean;
  isOutlined?: boolean;
  isCompact?: boolean;
  isHorizontal?: boolean;
  isInteractive?: boolean;
}

Prefer
interface CardProps {
  children: React.ReactNode;
  /** @default "elevated" */
  variant?: 'elevated' | 'outlined' | 'flat';
  /** @default "md" */
  padding?: 'sm' | 'md' | 'lg';
  /** @default "vertical" */
  direction?: 'vertical' | 'horizontal';
  onClick?: () => void;
}

Five booleans are really three independent dimensions: surface style (variant), density (padding), and layout (direction). Each dimension gets its own union prop. isInteractive is better expressed by the presence of onClick. If there's a click handler, the card is interactive.

Booleans create 32 combinations, many conflicting. Enums produce 18, and every single one is valid.

MUI: Card API