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.
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:
// ❌ This shouldn't be allowed - is it a button or a link?
<Button
onClick={() => console.log('clicked')}
href="/some-page"
/>
We can use TypeScript's discriminated unions and the never
type to make these props mutually exclusive. Here's how:
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:
// ✅ 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>
Let's look at a more complex example. Consider a payment form that accepts different payment methods:
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.
This pattern is particularly useful when:
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.