React props - struggling with discriminating union types

Viewed 1629

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

4 Answers

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} />;
};

Control flow of aliased conditions is available only since ts@4.4. So, using destructured withRouter variable doesn't carry any type information when used inside if statement before ts@4.4.

Then there is a subtle issue in your code. Your withRouter prop has type boolean and here:

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

You're narrowing it's type to true not to boolean (true | false).

Another issue is optional types always bring undefined value into the union. Thus you have to explicitly check for such a case. This check simultaneously check for explicit 'undefined' value or the case when !('withRouter' in props):

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

playground link

Since ts@4.4 you can use desctructured withRouter as aliased condition:

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

playground check

Because both union have withROuter prop, it is hard for TS to distinguish them.

I think union worth refactoring.

UPDATE - added overloading

import React, { FC } from 'react'

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


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


type WithStateProps = {
  tabs: ItemWithState[];
};

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

type TabsProps = WithStateProps | WithRouterProps;

const hasProperty = <Obj, Prop extends string>(obj: Obj, prop: Prop)
  : obj is Obj & Record<Prop, unknown> =>
  Object.prototype.hasOwnProperty.call(obj, prop);


const TabsWithRouter: FC<WithRouterProps> = (props: WithRouterProps) => null
const TabsWithState: FC<WithStateProps> = (props: WithStateProps) => null

type Overloading =
  & ((props: WithStateProps) => JSX.Element)
  & ((props: WithRouterProps) => JSX.Element)

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

const Test = () => {
  return (
    <div>
      <Tabs // With correct state props
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // With incorrect state props
        baseUrl="something"
        tabs={[{ name: "myname", active: true }]}
      />
      <Tabs // WIth correct router props
        withRouter
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth correct router props
        withRouter
        baseUrl="someurl"
        tabs={[{ name: "myname", path: "somepath" }]}
      />
      <Tabs // WIth incorrect router props
        withRouter
        tabs={[{ name: "myname", active: true }]}
      />
    </div>
  );

Now, TS is able to figure out where is withState and where is withRouter

Playground

Btw, both this and this questions might be interesting for you. TS does not play well with destructuring in term of working with unions

You could define a single TabsProps type:

type TabsProps = {
  withRouter: boolean;
  baseUrl?: string;
  tabs: ItemWithRouter[] | ItemWithState[];
};

and use it in your interface specific components like:

const TabsWithRouter: React.FC<TabsProps> = (props: TabsProps) => {
  const tabs = props.tabs as ItemWithRouter[];
  return (
    <div>
      <h3>TabsWithRouter Path: {tabs[0].path}</h3>
    </div>
  );
};

const TabsWithState: React.FC<TabsProps> = (props: TabsProps) => {
  const tabs = props.tabs as ItemWithState[];
  return (
    <div>
      <h3>TabsWithState Path: {tabs[0].active ? "true" : "false"}</h3>
    </div>
  );
};

https://codesandbox.io/s/vigilant-ptolemy-s2r3n

Related