I'm trying to make a <Menu>
component that accepts a prop items
which is one of two shapes:
Array<{ label: string, onClick: () => void }>
- onClick
is requiredArray<{ label: string, onClick?: () => void }>
- onClick
is optionalIt must be Shape 1
(onClick
required per item) if the <Menu>
is not given a onFallbackClick
prop. If onFallbackClick
is provided, then it can be Shape 2
(onClick
is optional on each item).
I tried this (TS Playground link is at bottom of post):
import React from 'react';
type TMenuProps<UItem extends TMenuItem> = UItem extends TMenuItemWhenMenuHasFallbackClick ? TMenuPropsWithFallbackClick<UItem> : TMenuPropsWithoutFallbackClick;
type TMenuPropsWithFallbackClick<UItem extends TMenuItemWhenMenuHasFallbackClick> = {
items: UItem[];
onFallbackClick: (item: UItem) => void;
};
type TMenuPropsWithoutFallbackClick = {
items: TMenuItemWhenMenuHasNoFallbackClick[];
onFallbackClick?: never;
};
type TMenuItemWhenMenuHasFallbackClick = { label: string; onClick: () => void };
type TMenuItemWhenMenuHasNoFallbackClick = { label: string; onClick?: () => void; };
type TMenuItem = TMenuItemWhenMenuHasFallbackClick | TMenuItemWhenMenuHasNoFallbackClick;
function Menu<UItem extends TMenuItem>(props: TMenuProps<UItem>) {
return (
<div>
{props.items.map(item => (
<button key={item.label} onClick={item.onClick || props.onFallbackClick}>
{item.label}
</button>
))}
</div>
);
}
This has two problems.
props.onFallbackClick
is for sure defined as item.onClick
was not defined in the code - item.onClick || props.onFallbackClick
<Menu items={[{label: 'A' as const }]} onFallbackClick={item => console.log('fallback, item.label:', item.label)} />
. It thinks item
arg is type any
.Any ideas on how to fix this?
You were close and on the right track, but you want to use the whole TMenuItem[]
array as a type parameter. Then we can use conditional types to choose whether to have a onFallbackClick
based on the whole array (rather than a union of each item).
The simplest demonstration (and vast oversimplification) of the difference is here.
type A = [{onClick?: () => void}, {onClick: () => void}]
type B = ({onClick?: () => void} | {onClick: () => void})[]
type Test1 = A extends B ? true : false
// ^? `true`
type Test2 = B extends A ? true : false
// ^? `false`
// IE. type A and B are not equivalent. A is indeed B, but B is not of A.
// or: all squares are rectangles, but not all rectangles are squares.
Notably, you are mistaken with your first error. It is a valid error by typescript, as your onFallbackClick
is not matching the type of React.MouseEventHandler<HTMLButtonElement>
, you likely meant to wrap this functionality.
<button key={item.label} onClick={item.onClick || (() => {props.onFallbackClick?.(item)})}>
Unfortunately, we do need to use ?.
with onFallbackClick
despite the fact that in theory this would not needed. But, better safe then sorry, especially on the implementation side. (Aside: the longer explanation requires justifying Typescript's design, which is beyond the scope of this question or my qualifications).
I have renamed some of the variables/types for readability, but it should be adaptable to your problem:
type TMenuItemWithOnClick = {
label: string;
onClick: () => void;
}
type MenuPropsWithoutFallback<T extends TMenuItemWithOnClick[]> = {
items: T
onFallbackClick?: never;
}
type TMenuItemWithMaybeOnClick = {
label: string;
onClick?: () => void;
}
type MenuPropsWithFallback<T extends TMenuItemWithMaybeOnClick[]> = {
items: T
onFallbackClick: (item: T[number]) => void;
}
type TMenuItemTypes = TMenuItemWithMaybeOnClick | TMenuItemWithOnClick
type MenuProps<T extends TMenuItemTypes[]> = T extends TMenuItemWithOnClick[]
? MenuPropsWithoutFallback<T>
: MenuPropsWithFallback<T>
function Menu<T extends TMenuItemTypes[]>(props: MenuProps<T>) {
return (
<div>
{props.items.map(item => (
<button key={item.label} onClick={item.onClick || (() => {props.onFallbackClick?.(item)})}>
{item.label}
</button>
))}
</div>
);
}
You can view this working on TS Playground, along with supplementary examples.