What is the correct way to extend Material UI ListItem?

Viewed 729

I'm using TypeScript 3.4.5 and Material UI 4.2 and with the following piece of code:

interface MyItemProps {
    name: string;
    value: string;
}

function Item({ name, value, ...props }: ListItemProps<'li', MyItemProps>): ReactElement {
    return (
        <ListItem {...props} className="item">
            <ListItemText primary={name} secondary={value || '-'} />
        </ListItem>
    );
}

I'm getting Type 'boolean' is not assignable to type 'true' error. Why?

I've been looking into the type definitions for ListItem, but I can't figure out what's going on:

export interface ListItemTypeMap<P, D extends React.ElementType> {
  props: P & {
    alignItems?: 'flex-start' | 'center';
    autoFocus?: boolean;
    button?: boolean;
    ContainerComponent?: React.ElementType<React.HTMLAttributes<HTMLDivElement>>;
    ContainerProps?: React.HTMLAttributes<HTMLDivElement>;
    dense?: boolean;
    disabled?: boolean;
    disableGutters?: boolean;
    divider?: boolean;
    focusVisibleClassName?: string;
    selected?: boolean;
  };
  defaultComponent: D;
  classKey: ListItemClassKey;
}

declare const ListItem: OverridableComponent<ListItemTypeMap<{ button?: false }, 'li'>> &
  ExtendButtonBase<ListItemTypeMap<{ button: true }, 'div'>>;

export type ListItemClassKey =
  | 'root'
  | 'container'
  | 'focusVisible'
  | 'default'
  | 'dense'
  | 'disabled'
  | 'divider'
  | 'gutters'
  | 'button'
  | 'secondaryAction'
  | 'selected';

export type ListItemProps<D extends React.ElementType = 'li', P = {}> = OverrideProps<
  ListItemTypeMap<P, D>,
  D
>;

export default ListItem;

The only thing that comes to my mind is "type widening", but I don't really know what's happening and why.

Could somebody please explain what's going on? Most importantly, what is the correct way of extending a Material UI component?

2 Answers

This appears to be a well established issue over on the material-ui github. Apparently it stems from using booleans to do union discrimination - I'm still new to TS so only have a general understanding of what that means!

The cleanest way to get past this which I found from those threads (without overwriting button with explicit any in the component props, ew) is by casting button as true directly on to the ListItem:

interface MyItemProps {
    name: string;
    value: string;
}

type MyListItem = ListItemProps<"li", MyItemProps>;

function Item({ name, value, button, ...props }: MyListItem): ReactElement {
    return (
        <ListItem {...props} className="item" button={button as true}>
            <ListItemText primary={name} secondary={value || '-'} />
        </ListItem>
    );
}

It all boils down to how intersection types work.

Basically, you can have one, or the other:

  • If the element type is "li", then the button prop must be set to false or undefined
  • If the element type is "div", then the button prop must be set to true.

So, in my case, because I was using "li" as a type parameter with the ListItemProps generic type, I can't just pass the button prop through. It must be omitted instead (or alternatively, set to false):

interface MyItemProps {
    name: string;
    value: string;
}

function Item({ name, value, button /* omit the prop, don't pass it down to ListItem */, ...props }: ListItemProps<'li', MyItemProps>): ReactElement {
    return (
        <ListItem {...props} className="item">
            <ListItemText primary={name} secondary={value || '-'} />
        </ListItem>
    );
}
Related