Steve Kinney

Prop Combinations And Unions

Component APIs are contracts—they define what combinations of props make sense and which ones don’t. But too often, we design interfaces that allow nonsensical combinations to slip through, creating runtime confusion and maintenance headaches. TypeScript gives us the tools to encode these rules directly into our types, making invalid states literally impossible to represent.

By the end of this guide, you’ll know how to design component APIs that permit valid combinations, limit problematic ones, and require essential groupings—all while providing excellent developer experience through IntelliSense and compile-time safety.

The Problem with Permissive Props

Consider a common scenario: a Dialog component that can be controlled or uncontrolled, with different behaviors based on how it’s used:

// ❌ This allows confusing combinations
interface DialogProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
}

function Dialog({ open, defaultOpen, onOpenChange, children }: DialogProps) {
  // What happens when both open AND defaultOpen are provided?
  // What if open is provided without onOpenChange?
  const [isOpen, setIsOpen] = useState(defaultOpen ?? false);

  // Confusing logic to handle all the edge cases...
  const actualOpen = open !== undefined ? open : isOpen;

  return actualOpen ? <div>{children}</div> : null;
}

This interface is technically flexible, but it’s also confusing. Nothing prevents these problematic combinations:

// All of these compile but create unclear behavior
<Dialog open={true} defaultOpen={false} /> {/* Which wins? */}
<Dialog open={isOpen} /> {/* Missing onOpenChange handler */}
<Dialog onOpenChange={setOpen} /> {/* Handler but no way to control */}

Pattern 1: Permit Specific Combinations

The first pattern is about creating clear, mutually exclusive variants. Here’s how to design a component that permits only valid controlled/uncontrolled patterns:

type ControlledDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  defaultOpen?: never; // Explicitly forbidden
};

type UncontrolledDialogProps = {
  defaultOpen?: boolean;
  open?: never; // Explicitly forbidden
  onOpenChange?: never; // Explicitly forbidden
};

type DialogProps = {
  children: React.ReactNode;
  title?: string;
} & (ControlledDialogProps | UncontrolledDialogProps);

function Dialog(props: DialogProps) {
  const { children, title } = props;

  // TypeScript narrows the type based on the presence of 'open'
  if ('open' in props) {
    // Controlled variant - TypeScript knows onOpenChange exists
    return (
      <dialog open={props.open}>
        {title && <h2>{title}</h2>}
        {children}
        <button onClick={() => props.onOpenChange(false)}>
          Close
        </button>
      </dialog>
    );
  }

  // Uncontrolled variant - manage state internally
  const [isOpen, setIsOpen] = useState(props.defaultOpen ?? false);

  return (
    <dialog open={isOpen}>
      {title && <h2>{title}</h2>}
      {children}
      <button onClick={() => setIsOpen(false)}>
        Close
      </button>
    </dialog>
  );
}

Usage becomes crystal clear:

// ✅ Controlled - all required props present
<Dialog
  open={isOpen}
  onOpenChange={setIsOpen}
>
  Content here
</Dialog>

// ✅ Uncontrolled - simple and self-contained
<Dialog defaultOpen={true}>
  Content here
</Dialog>

// ❌ These combinations are impossible
<Dialog open={true} defaultOpen={false} /> {/* TypeScript error */}
<Dialog open={isOpen} /> {/* Missing onOpenChange */}
Use the

never type to explicitly forbid props in specific variants. This makes the mutual exclusivity obvious to both TypeScript and developers reading the code.

Pattern 2: Limit Based on Conditions

Sometimes you want to limit certain prop combinations based on context. Here’s a Button component that adapts its available props based on its variant:

type BaseButtonProps = {
  children: React.ReactNode;
  disabled?: boolean;
  className?: string;
};

type PrimaryButtonProps = BaseButtonProps & {
  variant: 'primary';
  onClick: () => void;
  // Primary buttons must have click handlers
  href?: never;
  target?: never;
};

type SecondaryButtonProps = BaseButtonProps & {
  variant: 'secondary';
  onClick?: () => void; // Optional for secondary
  href?: never;
  target?: never;
};

type LinkButtonProps = BaseButtonProps & {
  variant: 'link';
  href: string;
  target?: '_blank' | '_self' | '_parent' | '_top';
  onClick?: never; // Links don't need onClick
};

type ButtonProps = PrimaryButtonProps | SecondaryButtonProps | LinkButtonProps;

function Button(props: ButtonProps) {
  const { children, disabled, className, variant } = props;

  const baseClasses = `btn btn-${variant} ${className || ''}`;

  if (variant === 'link') {
    return (
      <a
        href={props.href}
        target={props.target}
        className={baseClasses}
      >
        {children}
      </a>
    );
  }

  return (
    <button
      className={baseClasses}
      disabled={disabled}
      onClick={props.onClick}
    >
      {children}
    </button>
  );
}

This design limits prop combinations based on the button’s intended behavior:

// ✅ Primary buttons require onClick
<Button variant="primary" onClick={handleSubmit}>
  Submit Form
</Button>

// ✅ Link buttons require href
<Button variant="link" href="/dashboard" target="_blank">
  Go to Dashboard
</Button>

// ✅ Secondary buttons work with or without onClick
<Button variant="secondary">
  Cancel
</Button>

// ❌ Invalid combinations caught at compile time
<Button variant="primary" href="/somewhere" /> {/* href not allowed */}
<Button variant="link" onClick={handleClick} /> {/* onClick not allowed */}

Pattern 3: Require Dependent Props

Some props only make sense when used together. Here’s a pattern for requiring dependent combinations using conditional types:

type HasIcon<T> = T extends { icon: string }
  ? T & { 'aria-label': string } // Icon requires aria-label
  : T;

type HasTooltip<T> = T extends { tooltip: string }
  ? T & { 'aria-describedby'?: string } // Tooltip can have describedby
  : T;

type IconButtonProps = HasTooltip<HasIcon<{
  icon?: string;
  tooltip?: string;
  'aria-label'?: string;
  'aria-describedby'?: string;
  onClick: () => void;
  children?: never; // Icon buttons don't show text
}>>;

function IconButton(props: IconButtonProps) {
  if (!props.icon) {
    // Without icon, this becomes a regular button
    // But TypeScript still enforces the constraints above
    return <button onClick={props.onClick} />;
  }

  return (
    <button
      onClick={props.onClick}
      aria-label={props['aria-label']} // Required when icon exists
      aria-describedby={props['aria-describedby']}
      title={props.tooltip}
    >
      <Icon name={props.icon} />
    </button>
  );
}

Usage enforces accessibility requirements:

// ✅ Icon with required aria-label
<IconButton
  icon="search"
  aria-label="Search products"
  onClick={handleSearch}
/>

// ✅ Icon with tooltip and aria-label
<IconButton
  icon="help"
  tooltip="Get help with this feature"
  aria-label="Help"
  aria-describedby="help-tooltip"
  onClick={showHelp}
/>

// ❌ Icon without aria-label is caught
<IconButton icon="search" onClick={handleSearch} /> {/* Missing aria-label */}

Real-World Example: Form Field Combinations

Let’s build a comprehensive form field component that demonstrates all three patterns:

type BaseFieldProps = {
  name: string;
  label: string;
  error?: string;
  disabled?: boolean;
  required?: boolean;
};

// Pattern 1: Permit specific input types
type TextFieldProps = BaseFieldProps & {
  type: 'text' | 'email' | 'password' | 'tel';
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  maxLength?: number;
  // These don't make sense for text inputs
  multiple?: never;
  options?: never;
};

type SelectFieldProps = BaseFieldProps & {
  type: 'select';
  value: string | string[];
  onChange: (value: string | string[]) => void;
  options: Array<{ value: string; label: string; disabled?: boolean }>;
  // Pattern 2: Limit multiple based on value type
  multiple?: boolean;
  // These don't make sense for selects
  placeholder?: never;
  maxLength?: never;
};

type FileFieldProps = BaseFieldProps & {
  type: 'file';
  value?: never; // File inputs handle their own state
  onChange: (files: FileList | null) => void;
  accept?: string;
  multiple?: boolean;
  // These don't make sense for file inputs
  placeholder?: never;
  maxLength?: never;
  options?: never;
};

// Pattern 3: Require validation when needed
type WithValidation<T> = T extends { required: true }
  ? T & { validationMessage?: string }
  : T;

type FieldProps = WithValidation<TextFieldProps | SelectFieldProps | FileFieldProps>;

function Field(props: FieldProps) {
  const { label, name, error, disabled, required } = props;

  const renderInput = () => {
    switch (props.type) {
      case 'text':
      case 'email':
      case 'password':
      case 'tel':
        return (
          <input
            type={props.type}
            name={name}
            value={props.value}
            onChange={(e) => props.onChange(e.target.value)}
            placeholder={props.placeholder}
            maxLength={props.maxLength}
            disabled={disabled}
            required={required}
          />
        );

      case 'select':
        return (
          <select
            name={name}
            value={props.value}
            onChange={(e) => {
              if (props.multiple) {
                const values = Array.from(e.target.selectedOptions).map(o => o.value);
                props.onChange(values);
              } else {
                props.onChange(e.target.value);
              }
            }}
            multiple={props.multiple}
            disabled={disabled}
            required={required}
          >
            {props.options.map((option) => (
              <option
                key={option.value}
                value={option.value}
                disabled={option.disabled}
              >
                {option.label}
              </option>
            ))}
          </select>
        );

      case 'file':
        return (
          <input
            type="file"
            name={name}
            onChange={(e) => props.onChange(e.target.files)}
            accept={props.accept}
            multiple={props.multiple}
            disabled={disabled}
            required={required}
          />
        );
    }
  };

  return (
    <div className="field">
      <label htmlFor={name}>
        {label}
        {required && <span className="required">*</span>}
      </label>
      {renderInput()}
      {error && <span className="error">{error}</span>}
    </div>
  );
}

This design creates a robust, type-safe field component:

// ✅ Text field with proper props
<Field
  type="email"
  name="email"
  label="Email Address"
  value={email}
  onChange={setEmail}
  placeholder="you@example.com"
  required
/>

// ✅ Multi-select with array value
<Field
  type="select"
  name="skills"
  label="Skills"
  value={selectedSkills}
  onChange={setSelectedSkills}
  options={skillOptions}
  multiple
/>

// ✅ File upload with proper handlers
<Field
  type="file"
  name="resume"
  label="Upload Resume"
  onChange={handleFileChange}
  accept=".pdf,.doc,.docx"
/>

// ❌ Invalid combinations prevented
<Field type="text" options={[]} /> {/* options not allowed on text */}
<Field type="select" placeholder="Choose" /> {/* placeholder not allowed on select */}

Advanced Pattern: Function Overloads

For even more precise control, you can use function overloads to define exact prop combinations:

// Define overloads for different use cases
function Toast(props: {
  type: 'success';
  message: string;
  duration?: number;
}): JSX.Element;

function Toast(props: {
  type: 'error';
  message: string;
  action?: { label: string; onClick: () => void };
  persistent?: true; // Error toasts can be persistent
}): JSX.Element;

function Toast(props: {
  type: 'loading';
  message: string;
  duration?: never; // Loading toasts don't auto-dismiss
  persistent?: true;
}): JSX.Element;

// Implementation handles all cases
function Toast(props: {
  type: 'success' | 'error' | 'loading';
  message: string;
  duration?: number;
  action?: { label: string; onClick: () => void };
  persistent?: boolean;
}) {
  const { type, message, duration, action, persistent } = props;

  useEffect(() => {
    if (type === 'success' && !persistent && duration !== undefined) {
      const timer = setTimeout(() => dismiss(), duration);
      return () => clearTimeout(timer);
    }
  }, [type, persistent, duration]);

  return (
    <div className={`toast toast-${type}`}>
      <span>{message}</span>
      {action && (
        <button onClick={action.onClick}>
          {action.label}
        </button>
      )}
    </div>
  );
}

Runtime Validation with Zod

For components that receive props from external sources, combine your TypeScript patterns with runtime validation:

import { z } from 'zod';

const ControlledDialogSchema = z.object({
  open: z.boolean(),
  onOpenChange: z.function().args(z.boolean()).returns(z.void()),
  defaultOpen: z.undefined(),
});

const UncontrolledDialogSchema = z.object({
  defaultOpen: z.boolean().optional(),
  open: z.undefined(),
  onOpenChange: z.undefined(),
});

const DialogPropsSchema = z
  .object({
    children: z.any(),
    title: z.string().optional(),
  })
  .and(z.union([ControlledDialogSchema, UncontrolledDialogSchema]));

type DialogProps = z.infer<typeof DialogPropsSchema>;

function Dialog(props: DialogProps) {
  // Runtime validation for props from external sources
  const validatedProps = DialogPropsSchema.safeParse(props);

  if (!validatedProps.success) {
    throw new Error(`Invalid props: ${validatedProps.error.message}`);
  }

  // Your component logic here...
}

When to Use Each Pattern

Permit patterns work best when:

  • You have distinct modes of operation (controlled vs uncontrolled)
  • Props have fundamentally different meanings in different contexts
  • You want to prevent conceptual confusion

Limit patterns are ideal for:

  • Components with multiple variants that need different props
  • Preventing props that don’t make sense together
  • Creating focused, single-purpose interfaces

Require patterns shine when:

  • Accessibility requirements must be enforced
  • Props have strong dependencies (e.g., validation rules)
  • You want to guide developers toward best practices

Performance and Maintainability

These patterns are compile-time constructs—they add zero runtime overhead. However, consider these tradeoffs:

  • Complex union types can slow TypeScript compilation on large codebases
  • Deeply nested conditionals may create confusing error messages
  • Over-engineering simple components reduces readability

The goal is to use these patterns judiciously—when they prevent real bugs and improve the developer experience, not just because you can.

Common Pitfalls

Over-constraining Simple Cases

// ❌ Overkill for a simple optional prop
type OverEngineered = { showIcon: true; iconName: string } | { showIcon: false; iconName?: never };

// ✅ Sometimes simple is better
type Simple = { showIcon?: boolean; iconName?: string };

Forgetting About Partial Application

// ❌ This breaks when props are spread or partially applied
type StrictProps = { a: string } & { b: string };

// ✅ More resilient to partial application
type FlexibleProps = { a: string; b?: string } | { a?: string; b: string };

Making Maintenance Harder

// ❌ Brittle - adding new variants requires touching everything
type Variant = 'primary' | 'secondary' | 'danger';
type ButtonProps = Variant extends 'primary'
  ? PrimaryProps
  : Variant extends 'secondary'
    ? SecondaryProps
    : DangerProps;

// ✅ More maintainable union
type ButtonProps = PrimaryProps | SecondaryProps | DangerProps;

Next Steps

Now that you can design precise component APIs, consider exploring:

  • Generic constraint patterns for reusable prop combinations
  • Template literal types for dynamic prop validation
  • Mapped types for transforming existing interfaces
  • Branded types for even stronger guarantees

Remember: the best component API is one that makes correct usage easy and incorrect usage impossible. These patterns help you build that kind of bulletproof interface—use them when they add value, not complexity.

Last modified on .