Loading...
Table of Contents

Making React Props Mutually Exclusive with TypeScript's 'never' Type

Have you ever worked with a React component that accepts different combinations of props, but certain props shouldn't be used together? TypeScript can help us enforce these constraints at compile time using the never type. Let's explore this pattern with real-world examples.

The Problem: Conflicting Props

Let's look at a real-world example. Consider a Button component that can either act as a regular button or as a link, but never both simultaneously:

components/button.tsx

// ❌ This shouldn't be allowed - is it a button or a link?
<Button
    onClick={() => console.log('clicked')} 
    href="/some-page"
/>

The Solution: Discriminated Unions with 'never'

We can use TypeScript's discriminated unions and the never type to make these props mutually exclusive. Here's how:

components/button.tsx

type RegularButtonProps = {
  onClick: () => void;
  type?: 'button' | 'submit' | 'reset';
  href?: never;  // Can't be used with regular button props //
  target?: never;
  rel?: never;
};

type LinkButtonProps = {
  href: string;
  target?: '_blank' | '_self';
  rel?: string;
  onClick?: never;  // Can't be used with link props //
  type?: never; 
};

type ButtonProps = {
  children: React.ReactNode;
  className?: string;
} & (RegularButtonProps | LinkButtonProps); // This is a discriminated union 

const Button: React.FC<ButtonProps> = (props) => {
  if ('href' in props) {
    const { href, target, rel, className, children } = props;
    return (
      <a href={href} target={target} rel={rel} className={className}>
        {children}
      </a>
    );
  }

  const { onClick, type = 'button', className, children } = props;
  return (
    <button type={type} onClick={onClick} className={className}>
      {children}
    </button>
  );
};

Each interface uses the never type to explicitly mark props that don't apply to its use case. For example, the ButtonAsLink interface marks onClick as never since it's not valid for a link-style button.

Now TypeScript will give us helpful errors:

components/button.tsx

// ✅ Regular button usage 
<Button onClick={() => console.log('clicked')}>
  Click me
</Button>

// ✅ Link button usage 
<Button href="/about" target="_blank">
  Go to About
</Button>

// ❌ TypeScript Error: Can't mix button and link props //
<Button
  onClick={() => {}}
  href="/about"
>
  Invalid
</Button>

Real-World Example: Payment Form

Let's look at a more complex example. Consider a payment form that accepts different payment methods:

components/payment-form.tsx

type CreditCardPayment = {
  paymentType: 'credit-card';
  cardNumber: string;
  expiryDate: string;
  cvv: string;
  paypalEmail?: never; 
  bankAccountNumber?: never; 
};

type PayPalPayment = {
  paymentType: 'paypal';
  paypalEmail: string;
  cardNumber?: never; 
  expiryDate?: never; 
  cvv?: never; 
  bankAccountNumber?: never; 
};

type BankTransferPayment = {
  paymentType: 'bank-transfer';
  bankAccountNumber: string;
  cardNumber?: never; 
  expiryDate?: never; 
  cvv?: never; 
  paypalEmail?: never; 
};

type PaymentFormProps = CreditCardPayment | PayPalPayment | BankTransferPayment;  

const PaymentForm: React.FC<PaymentFormProps> = (props) => {
  return (
    <form>
      {props.paymentType === 'credit-card' && (
        <>
          <input type="text" value={props.cardNumber} />
          <input type="text" value={props.expiryDate} />
          <input type="text" value={props.cvv} />
        </>
      )}
      {props.paymentType === 'paypal' && (
        <input type="email" value={props.paypalEmail} />
      )}
      {props.paymentType === 'bank-transfer' && (
        <input type="text" value={props.bankAccountNumber} />
      )}
    </form>
  );
};

As before, each interface uses the never type to explicitly mark props that don't apply to its use case. The Discriminated Union is the type of the props (PaymentFormProps), and allows for all the possible combinations of the props.

When to Use This Pattern

This pattern is particularly useful when:

  1. Your component has multiple modes of operation that are mutually exclusive
  2. Certain props only make sense when used together
  3. You want to prevent prop combinations that would create unclear or invalid states

Conclusion

Using TypeScript's never type with discriminated unions is a powerful way to create type-safe React components that prevent invalid prop combinations. This pattern helps catch errors at compile time rather than runtime and makes your component APIs more self-documenting.

Remember, good type definitions are like good documentation - they help other developers (including your future self) understand how to use your components correctly. The extra time spent setting up these types will save hours of debugging and prevent potential bugs in production.

© 2024 - Mo Sayed