Home > Mobile >  Discriminating Union showing errors during destructuring inside component
Discriminating Union showing errors during destructuring inside component

Time:02-05

I am having a type with discriminating unions that looks like this:

interface WithAction {
  isActionBtnRequired: true;
  content: string;
  containerClass?: string;
  customActionComponent?: ReactNode;
  actionComponentPosition?: ActionComponentPosition;
  actionBtnText?: string;
  clickHandler?: () => void;
}

interface WithoutAction {
  isActionBtnRequired: false;
  content: string;
  containerClass?: string;
}

type IInfoBoxProps = WithAction | WithoutAction;

type ActionComponentPosition = "floating-right" | "next-to-content";

The component should strictly take content as the prop by default. This component can take any custom action component ( it is set to button by default ). This action component can be placed either at the rightmost side by passing floating-right as the value to actionComponentPosition prop, or next to the content by passing next-to-content value. actionBtnText and clickHandler should be passed when I have to display the action component.

But when I am destructuring the props, it throws property is not found in the interface .

Can someone help here https://codesandbox.io/s/updated-file-upload-forked-65eut6?file=/src/App.tsx:0-1588

import * as React from "react";
import { ReactNode } from "react";
import "./styles.css";

interface WithAction {
  isActionBtnRequired: true;
  content: string;
  containerClass?: string;
  customActionComponent?: ReactNode;
  actionComponentPosition?: ActionComponentPosition;
  actionBtnText?: string;
  clickHandler?: () => void;
}

interface WithoutAction {
  isActionBtnRequired: false;
  content: string;
  containerClass?: string;
}

type IInfoBoxProps = WithAction | WithoutAction;

type ActionComponentPosition = "floating-right" | "next-to-content";

export const App = (props: IInfoBoxProps) => {
  const {
    containerClass = "",
    isActionBtnRequired = true,
    customActionComponent = null,
    actionComponentPosition = "floating-right",
    content,
    actionBtnText = "",
    clickHandler
  } = props;

  const renderActionBtn = (): ReactNode =>
    customActionComponent ? (
      customActionComponent
    ) : (
      <button onClick={clickHandler} className={"info-box-action-button"}>
        {actionBtnText}
      </button>
    );

  return (
    <div
      className={`info-box-container ${containerClass ? containerClass : ""}`}
    >
      <div className="info-box-section">
        <span className={"info-box-icon"}>icon</span>
        <div
          className={`${
            actionComponentPosition === "floating-right"
              ? "info-box-text-stretch"
              : "info-box-text"
          }`}
        >
          <span>{content}</span>
        </div>
        {isActionBtnRequired ? renderActionBtn() : null}
      </div>
    </div>
  );
};

CodePudding user response:

The problem you have is that union types are somewhat counter-intuitive. Some people may think that creating a union type would create a type whose properties are those of the united types combined. However, this is not true. Union types work at a higher level. This is a bit more difficult to see with object so I hope this example will cast some light for your understanding:

interface Human {
  name: string;
  age: number;
}

type NamedHuman = string | Human;

With this union, it it clear it won't try to mix the properties of both type because, well, there aren't properties to mix in string (sort of) to begin with. The same happens with objects. When you create a union of 2 types, typescript understands that you want either the first type or the second but definitely not a combination of them both.

Typescript, however, is clever/nice enough to understand that, despite you only want one type or the other, if those types have overlapping properties, those properties will be guaranteed to appear in the result. That's why, in you case, you don't get any issue on containerClass, isActionBtnRequired, and content. This happens even if you have optional properties since an optional property is no other than a union with undefined. This is why containerClass is still present in the result (as it's type matches 100% on both parent types) but not the other (as they are missing at least 1 type).

Now, all of this is understandable but how do you resolve your issue? Well, you will first need to know what type you actually have. You can build a guard function (or two) to know what type you are dealing with:

function isWithAction(props: IInfoBoxProps) props is WithAction {
   return props.isActionBtnRequired; // You might want to review this assertion to something that makes sense to your case
}

With this function you can now segregate the types:

export const App = (props: IInfoBoxProps) => {
  const {
    containerClass = "",
    isActionBtnRequired = true,
    content,
  } = props; // Those are the common properties
  let customActionComponent = null;
  let actionComponentPosition = "floating-right";
  let actionBtnText = "";
  let clickHandler;

  if (isWithAction(props)) {
    customActionComponent = props.customActionComponent;
    actionComponentPosition = props.actionComponentPosition;
    actionBtnText = props.actionBtnText;
    clickHandler = props.clickHandler
  }

  // Rest of the code
};

However, let me make you a reflection about your code. Checking your code I've realised that all the matching properties between WithActions and WithoutActions are actually the ones in WithoutActions and the other properties in WithActions are options. This suggests me that you don't need the union type. You can just request WithActions and destruct the object the way you intended (maybe you would need to change the isActionBtnRequired to boolean).

--- Edit to better explain my last point ---

Realising that WithAction and WithoutAction are pretty much the same data structure, I would leave WithAction only and accept this in the compontent. Perhaps, I'd rename it to Properties to give it a generic name:

    // I've renamed this
    //    vvvvvvvvvv
interface Properties {
  isActionBtnRequired: boolean; // <- I also changed this
  content: string;
  containerClass?: string;
  customActionComponent?: ReactNode;
  actionComponentPosition?: ActionComponentPosition;
  actionBtnText?: string;
  clickHandler?: () => void;
}

With this new data structure, you can keep the code as you had it:

                     // I've changed this
                     //    vvvvvvvvvv
export const App = (props: Properties) => {
  const {
    containerClass = "",
    isActionBtnRequired = true,
    customActionComponent = null,
    actionComponentPosition = "floating-right",
    content,
    actionBtnText = "",
    clickHandler
  } = props;

  // Rest of the code
};

This, of course, assumes that you are happy with having only 1 type. If you still need to have the 2 mentioned data types, you can make one to extend the other (I think this is what resembles the most your original idea of combining both data types with a union type):

interface WithoutAction {
  isActionBtnRequired: boolean;
  content: string;
  containerClass?: string;
}

                  // Notice this part here
                  // vvvvvvvvvvvvvvvvvvvvv
interface WithAction extends WithoutAction {
  customActionComponent?: ReactNode;
  actionComponentPosition?: ActionComponentPosition;
  actionBtnText?: string;
  clickHandler?: () => void;
}

export const App = (props: WithAction) => {
  const {
    containerClass = "",
    isActionBtnRequired = true,
    customActionComponent = null,
    actionComponentPosition = "floating-right",
    content,
    actionBtnText = "",
    clickHandler
  } = props;

  // Rest of the code
};
  • Related