Enumerated Variants
Replacing boolean prop explosion with string union enums for size, variant, color, and other visual dimensions.
interface ButtonProps {
children: React.ReactNode;
isSmall?: boolean;
isLarge?: boolean;
}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.
interface AlertProps {
children: React.ReactNode;
isError?: boolean;
isWarning?: boolean;
isSuccess?: boolean;
isInfo?: boolean;
}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.
interface TextProps {
children: React.ReactNode;
alignLeft?: boolean;
alignCenter?: boolean;
alignRight?: boolean;
alignJustify?: boolean;
}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.
interface ChipProps {
label: string;
isOutlined?: boolean;
isFilled?: boolean;
isClickable?: boolean;
isDeletable?: boolean;
isPrimary?: boolean;
isSecondary?: boolean;
}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.
interface DataViewProps<T> {
data: T[];
isLoading?: boolean;
isError?: boolean;
isEmpty?: boolean;
isRefreshing?: boolean;
}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.
interface StackProps {
children: React.ReactNode;
/** Gap between items in pixels. */
gap?: number;
/** Padding in pixels. */
padding?: number;
}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.
interface ButtonProps {
children: React.ReactNode;
icon?: React.ReactNode;
isIconBefore?: boolean;
isIconAfter?: boolean;
isIconOnly?: boolean;
}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.
interface CardProps {
children: React.ReactNode;
isElevated?: boolean;
isOutlined?: boolean;
isCompact?: boolean;
isHorizontal?: boolean;
isInteractive?: boolean;
}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.