reactjstypescriptreact-proptypesdiscriminated-unionunion-types

React props - struggling with discriminating union types


I have two components with similar props, but there is a crucial difference. One component, called TabsWithState takes only a single prop tabs, which is an array of objects of the following shape:

interface ItemWithState {
  name: string;
  active: boolean;
}

interface WithStateProps {
  tabs: ItemWithState[];
};

Another similar component, called TabsWithRouter, requires the item shape to be different:

interface ItemWithRouter {
  name: string;
  path: string;
}

interface WithRouterProps {
  tabs: ItemWithRouter[];
};

I am trying to create a generic component Tabs, which would account for both scenarios. I want to be able to write a <Tabs /> component, where if a withRouter prop is passed, the tabs property must be of type ItemWithRouter[]. But if no withRouter prop is passed, it must of type ItemWithState[]. Also, if withRouter is passed, Tabs should also accept an optional baseUrl prop.

I tried creating a discriminating union type, like this:

type WithStateProps = {
  withRouter?: never;
  baseUrl?: never;
  tabs: ItemWithState[];
};

type WithRouterProps = {
  withRouter: boolean;
  baseUrl?: string;
  tabs: ItemWithRouter[];
};

type TabsProps = WithStateProps | WithRouterProps;

In my generic Tabs component, I want to render TabsWithRouter if withRouter is present, and TabsWithState if withRouter is not present:

const Tabs = (props: TabsProps) => {
  const { withRouter } = props;
  if (withRouter) {
    return <TabsWithRouter {...props} />;
  }
  return <TabsWithState {...props} />;
};

I initially tried to define TabsWithRouter and TabsWithState as being function components that accept WithRouterProps and WithStateProps, respectively:

const TabsWithRouter: React.FC<WithRouterProps> = (props: WithRouterProps) => { ... }
const TabsWithState: React.FC<WithStateProps> = (props: WithStateProps) => { ... }

But I get the error Types of property 'withRouter' are incompatible. Type 'undefined' is not assignable to type 'boolean'., as can be seen in this ts playground

So I tried instead to type TabsWithRouter and TabsWithState as accepting TabsProps as their props:

const TabsWithRouter: React.FC<TabsProps> = (props: TabsProps) => {
  const { tabs } = props;
  console.log(tabs[0].path)
  return null
}
const TabsWithState: React.FC<TabsProps> = (props: TabsProps) => {
  const { tabs } = props;
  console.log(tabs[0].active)
  return null
}

But in this case, trying to access tabs[x].path or tabs[x].active gives me the error Property 'active' does not exist on type 'ItemWithState | ItemWithRouter'. Property 'active' does not exist on type 'ItemWithRouter', as can be seen in this ts playground.

Interestingly, in both cases, when I actually try to use the component, the props are behaving correctly, as can be seen in some examples at the bottom of either ts playground.

I feel like I'm close, but I'm struggling to get these discriminating union types to behave in the way that I want so that typescript stops erroring. I've read many posts on here asking similar questions, but I can't seem to apply them to what is going wrong in my scenario.

Edit:

As per request, here is my tsconfig.json:

{
  "extends": "./tsconfig.paths.json",
  "compilerOptions": {
    "baseUrl": "src",
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "types": [
      "cypress",
      "cypress-file-upload",
      "jest"
    ],
    "downlevelIteration": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": [
    "src"
  ]
}

Edit2

this ts playground shows that captain-yossarian's solution doesn't enforce the kinds of types I'm looking to enforce on the Tabs component


Solution

  • How about something simple like this... giving TypeScript a hint using as WithRouterProps or as WithStateProps as appropriate?

    const Tabs = (props: TabsProps) => {
      const { withRouter } = props;
      if (withRouter !== undefined) {
        return <TabsWithRouter {...props as WithRouterProps} />;
      }
      return <TabsWithState {...props as WithStateProps} />;
    };