Making two mutually-exclusive component props impossible to pass together
Button renders either a link or a real button. A link needs href and must not take onClick; a button needs onClick and must not take href. Today both are optional, so passing both — or neither — compiles happily and breaks at runtime.
Retype ButtonProps so that the compiler rejects the invalid combinations, and so that inside the component props.href is reachable only on the link branch. No as, no any.
type ButtonProps = {
label: string;
variant: 'link' | 'button';
href?: string;
onClick?: () => void;
};
// your code here — retype ButtonProps
export function Button(props: ButtonProps) {
return props.variant === 'link'
? <a href={props.href}>{props.label}</a>
: <button onClick={props.onClick}>{props.label}</button>;
}
Write the implementation.
Model the props as a union discriminated by a literal field, not one object with optional members: { variant: 'link'; href: string } | { variant: 'button'; onClick(): void }. Optional props make every combination legal; the union reaches href only after narrowing on variant, so invalid mixes stop compiling.
- ✗Expressing variants as optional props on one interface, which makes every mix legal
- ✗Believing TypeScript cannot express mutually exclusive properties at compile time
- ✗Omitting the literal discriminant, so narrowing inside the component has nothing to test
- →How does the compiler narrow
propsinside the component once the union is discriminated? - →How would you extend this to a third variant without touching the existing two?
The solution
type ButtonProps =
| { variant: 'link'; label: string; href: string }
| { variant: 'button'; label: string; onClick: () => void };
export function Button(props: ButtonProps) {
return props.variant === 'link'
? <a href={props.href}>{props.label}</a> // href visible, onClick is not
: <button onClick={props.onClick}>{props.label}</button>;
}
<Button variant="link" label="Docs" href="/docs" />; // ✅
<Button variant="button" label="Save" onClick={save} />; // ✅
<Button variant="link" label="X" href="/x" onClick={save} />; // ❌ onClick does not exist
<Button variant="button" label="X" />; // ❌ onClick is required
Why optional props do not catch this
{ href?: string; onClick?: () => void } describes four states: neither, href only, onClick only, both. Three of them compile and two of them are nonsense. A type that admits an impossible state is not describing the component.
A union with a literal discriminant leaves exactly two states and ties each one to its own props. Inside the component, props.variant === 'link' narrows the union to a single member, so props.href is available and props.onClick does not even exist on that branch.
Shared fields can be factored out with an intersection so they are not repeated:
type Common = { label: string };
type ButtonProps = Common & (
| { variant: 'link'; href: string }
| { variant: 'button'; onClick: () => void }
);