Как сделать невозможной передачу двух взаимоисключающих пропсов компонента
Button рисует либо ссылку, либо настоящую кнопку. Ссылке нужен href и не нужен onClick; кнопке нужен onClick и не нужен href. Сейчас оба необязательны, поэтому передача обоих — или ни одного — спокойно компилируется и падает в рантайме.
Перетипизируйте ButtonProps так, чтобы компилятор отверг неверные комбинации, а внутри компонента props.href был доступен только в ветке ссылки. Никаких as, никакого any.
type ButtonProps = {
label: string;
variant: 'link' | 'button';
href?: string;
onClick?: () => void;
};
// ваш код здесь — перетипизируйте ButtonProps
export function Button(props: ButtonProps) {
return props.variant === 'link'
? <a href={props.href}>{props.label}</a>
: <button onClick={props.onClick}>{props.label}</button>;
}
Допишите реализацию.
Опишите пропсы объединением, размеченным по литеральному полю, а не одним объектом с необязательными членами: { variant: 'link'; href: string } | { variant: 'button'; onClick(): void }. Необязательные пропсы делают легальной любую комбинацию; объединение открывает href только после сужения по variant.
- ✗Выражать варианты необязательными пропсами одного интерфейса, из-за чего легальна любая смесь
- ✗Считать, что TypeScript не умеет выражать взаимоисключающие свойства на этапе компиляции
- ✗Забыть литеральный разметочный признак, и тогда сужению внутри компонента не за что зацепиться
- →Как компилятор сужает
propsвнутри компонента, когда объединение размечено? - →Как добавить третий вариант, не трогая два существующих?
Решение
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 виден, onClick — нет
: <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 не существует
<Button variant="button" label="X" />; // ❌ onClick обязателен
Почему необязательные пропсы это не ловят
{ href?: string; onClick?: () => void } описывает четыре состояния: ни одного, только href, только onClick, оба. Три из них компилируются, но два — бессмысленны. Тип, который допускает невозможное состояние, не описывает компонент.
Объединение с литеральным разметочным полем оставляет ровно два состояния и связывает каждое со своим набором пропсов. Внутри компонента props.variant === 'link' сужает объединение до одного члена, поэтому props.href доступен, а props.onClick в этой ветке даже не существует.
Общие поля можно вынести пересечением, чтобы не дублировать:
type Common = { label: string };
type ButtonProps = Common & (
| { variant: 'link'; href: string }
| { variant: 'button'; onClick: () => void }
);