Search code examples
typescript

Discriminated union by top-level key and array key


I'm trying to make a <Menu> component that accepts a prop items which is one of two shapes:

  • Shape 1: Array<{ label: string, onClick: () => void }> - onClick is required
  • Shape 2: Array<{ label: string, onClick?: () => void }> - onClick is optional

It 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.

  1. On line 23, Typescript can't figure out that props.onFallbackClick is for sure defined as item.onClick was not defined in the code - item.onClick || props.onFallbackClick
  2. The inference is not working out when I do <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?

TS Playground - https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAJQKYEMDG8BmUIjgcilQ3wG4AocmATzCTgBUBZJAOwFcAFHMAZwB4AqgEkYSPEgAeY1gBNejFh1HiAfHAC8cEWInS28xW3YqQAdQAWbJewASKXgDEUAGxcAjdAGsAwi+BoXnAA-EYc3BB8ZsAwFs5unoF+AV5CpuoAXGFcPLzRsRDsMPEe3smBFFS09MzGEVExca6lSf6BabpwUjKGtcq6ltbG9k7Nib5tXupaAN7kcHAx4rxZOuIA2gC6FAsQrCXj5V5ZABRLIKumAJSa6gBuEMCyFAC+lTR02fV5jYXFY2VJpo4HMFucVtlTINWDYRgA5CAHQEpLY7OB7JGtFLBLKsJB3JBQV7vaqQgZWGHDByYiYpYEzOAuFDuJAuLK8GBQYCsADmpHRrCOpxuGnuj1kcDeVU+fRM5KGHHhiIBWMC9MZzNZ7M53L5AqOOLgJxFYqe-KlHxqNlMwNlUIpsOpKtpaoAPmTxNDHbwETSjpVMOxWBhgHs4DYOuIuvo5Ao7bpVCcwLksrLvpGQKobqC4EQYOwoKwjfMFnB+LJgHdVCXSyDk5FeAA6cGNkAoMBnTqi4u12v8dxFGBhrxIagaGbnRtMlkuF76ybjyd7I5wV3u+t8RsY51HF7V3u9ie6Kea2c13v8AD0A5gQ9Y+97VyuL3PV4rVZLV1elH4NkWul4cd1hmactQIABBfA4AcOA0D2DlJU2OdtwSZFAkXLt1Dg1heAgFwkCnCAeROfBMGdAAaf9xBPGcMnwSjJ1AlxnzgS9qyAA


Solution

  • Explanation

    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).

    Solution

    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.