DEV Community

EL May
EL May

Posted on • Edited on

Conditional React props with TypeScript

A prop which should only be set when another prop has a specific value.

Relationships between React component props can make you feel the pinch. This article will be your road-map to conditional props pattern employed using Typescript. I will propose different situations and demonstrate the answers to these questions:

How can we create a dependent relationship between several props using TypeScript?

What can we do to have it generate TypeScript errors when a relationship is broken?

Conflicting properties

Working on a design system, I had to create an avatar component. To pass props to the avatar component, different conditions were present:

  • If i pass the icon prop i can't pass the src prop
  • If i pass the src prop i can't pass the icon prop

Here an example for the simple avatar component without the conditions



type AvatarProps = {
  icon?: JSX.Element;
  src?: string;
  children:React.ReactNode;
};

export const Avatar = (props: AvatarProps): JSX.Element => {
  const { icon, src } = props;
  return (
    <div>
      {icon && icon}
      {JSON.stringify(src)}
      {children}
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

If we import the component while passing both props, the component will not raise any errors.

Therefore, we have to provide an indication for the developer to tell them that passing the two in the same time is forbidden by just throwing a typescript error.

To achieve that , we can create union type using two types that reflect the two scenarios our component supports:



interface CommonProps {
  children?: React.ReactNode

  // ...other props that always exist
}

type ConditionalProps =
  | {
      icon?: JSX.Element;
      src?: never;
    }
  | {
      icon?: never;
      src?: string;
    };

type Props = CommonProps & ConditionalProps  

export const Avatar = (props: Props): JSX.Element => {
  const { icon, src } = props;
  return (
    <div>
      {icon && icon}
      {JSON.stringify(src)}
      {children}
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

For those of you who are already familiar with TypeScript, that should be sufficient information

However, in just a few lines of code, there is a lot going on. Let's break it down into chunks if you're wondering about what it all means and how it all works.



interface CommonProps {
  children: React.ReactNode

  // ...other props that always exist
}


Enter fullscreen mode Exit fullscreen mode

CommonProps is your typical props definition in TypeScript. It’s for all of the “Common” props that figure in all scenarios and that aren’t dependent on other props. In addition to children, there might be shadow, size, shape, etc.



type ConditionalProps =
// If i pass the icon prop i can't pass the src prop
  | {
      icon?: JSX.Element;
      src?: never;
    }
// If i pass the src prop i can't pass the icon prop
  | {
      src?: string;
      icon?: never;
    };


Enter fullscreen mode Exit fullscreen mode

ConditionalProps is where the magic happens. It’s what’s called a ”discriminated union.” It’s union of object definitions.

Let’s break it down further and we’ll come back to see how the discriminated union works for us.



{
 icon?: JSX.Element;
 src?: never;
} 


Enter fullscreen mode Exit fullscreen mode

The first part of the discriminated union is when the icon prop is defined, In this case, we want the src prop to be invalid. It shouldn’t be able to be set.



{   
 icon?: never;
 src?: string;
};


Enter fullscreen mode Exit fullscreen mode

The second part is when the icon prop is unspecified (undefined). Then we can pass the src props with no problems



type ConditionalProps =
  | {
      icon?: JSX.Element;
      src?: never;
    }
  | {
      icon?: never;
      src?: string;
    };


Enter fullscreen mode Exit fullscreen mode

So now back to the entire discriminated union. It’s saying that the configuration for the icon and src props can either be the first case or the second case.

It's worth noting that we've used the keyword never in this example. The best explanation of this keyword can be found in the TypeScript documentation:

“TypeScript will use a never type to represent a state which shouldn’t exist.”

To reiterate, we defined two types for two scenarios and combined them using the union operator.



type Props = CommonProps & ConditionalProps  


Enter fullscreen mode Exit fullscreen mode

Props becomes the intersection of CommonProps and ConditionalProps.

Props is the combination of the two types. So it’ll have all the properties from CommonProps as well as this dependent relationship we created with ConditionalProps.

Now finally, in the Avatar component, both the icon and src props will be of there type respectively JSX.Element | undefined and string | undefined So their types come out straightforward as if you hadn’t created the dependent relationship.

Now if we try to provide both props, we will see a TypeScript error:

error image

Conditional prop variation

I needed to create a component with different variants, for each variant we have a set of props .

We want those props to be provided only when a matching variant is selected.

in our case we have 3 variants "text" | "number" | "element"

  • If the we chose to set the variant to text , we need to have a message prop of type string, and we can't set componentName prop
  • If the we chose to set the variant to number , we need to have a message props of type number, and we can't set componentName prop
  • If we do pass the variant as element , here we can use finally componentName also the message prop will become of type JSX.Element

Lets take a look at this example



interface CommonProps {
  children?: React.ReactNode;
  // ...other props that always exist
}
type ConditionalProps =
  | {
      componentName?: string;
      message?: JSX.Element;
      variant?: "element";
    }
  | {
      componentName?: never;
      message?: string;
      variant?: "text";
    }
  | {
      componentName?: never;
      message?: number;
      variant?: "number";
    };

type Props = CommonProps & ConditionalProps;

export const VariantComponent = (props: Props): JSX.Element => {
  const { message, componentName, variant = "element", children } = props;
  return (
    <div>
      {message && message}
      {variant === "element" && componentName}
      {children}
    </div>
  );
};



Enter fullscreen mode Exit fullscreen mode


/* 
 * If the we chose to set the variant to text,
 * we need to have a message props of type string,
 * We can't set componentName prop
 */

{
 componentName?: never;
 message?: string;
 variant?: "text";
}


Enter fullscreen mode Exit fullscreen mode


/*
 * If the we chose to set the variant to number,
 * we need to have a message props of type number,
 * and we can't set componentName prop
 */
{
 componentName?: never;
 message?: number;
 variant?: "number";
}


Enter fullscreen mode Exit fullscreen mode


/*
 * If we do pass the variant as element, 
 * here we can use finally componentName
 * also the message prop will become of type JSX.Element
 */
{
 componentName: string;
 message?: JSX.Element;
 variant?: "element";
}


Enter fullscreen mode Exit fullscreen mode

Once we set the variant prop , TypeScript narrows down component’s type to their respective desired properties and tells you what you need to provide

Conditional props for collection with generic type

For our next use case, let’s try defining conditional props for a Select component. Our component needs to be flexible enough to accept an array of strings or objects for its options property.

If the component receives an array of objects, we want the developer to specify which fields of those objects we should use as a label and value.\

Conditional types for collection property



type SelectProps<T> =
  | {
      options: Array<string>;
      labelProp?: never;
      valueProp?: never;
    }
  | {
      options: Array<T>;
      labelProp: keyof T;
      valueProp: keyof T;
    };

export const Select = <T extends unknown>(props: SelectProps<T>) => {
  return <div>{JSON.stringify(props)}</div>;
};



Enter fullscreen mode Exit fullscreen mode

To match the object that the user provide to the select. we can use generics in TypeScript.



{
 options: Array<T>;
 labelProp: keyof T;
 valueProp: keyof T;
}


Enter fullscreen mode Exit fullscreen mode

In our second type, we change the options prop from Array<Object> to Array<T> for our generic object. The client has to provide an array of items of the generic object type.

We're using the keyof keyword to tell TypeScript that we're expecting labelProp and valueProp to be generic object fields.

Now when you try to provide valueProp or labelProp, you’ll see a nice autocomplete suggestion based on the fields of the options items.

However, there is a minor change that we must make in order to avoid certain issues. We want to make sure that the generic object we've been given is a custom object rather than a primitive, such as a string:



type SelectProps<T> = T extends string
  ? {
      options: Array<string>;
      labelProp?: never;
      valueProp?: never;
    }
  : {
      options: Array<T>;
      labelProp: keyof T;
      valueProp: keyof T;
    };

export const Select = <T extends unknown>(props: SelectProps<T>) => {
  return <div>{JSON.stringify(props)}</div>;
};


Enter fullscreen mode Exit fullscreen mode

Here we changed the union type by ternary operator to check if our generic type is a string, and based on that, we set our component’s type to the appropriate option.

Here’s a link to the code sandbox for this tutorial.

Buy Me A Coffee

Top comments (5)

Collapse
 
nickofthyme profile image
Nick Partridge • Edited

The biggest thing I took away from this is the need to represent all properties across all of the discriminated unions. The ?: never type should be used for all unused properties in a specific union rather than simply omitting these unused properties. Not doing will cause issues as typescript is not able to choose from the correct union when properties are omitted. 👍

Collapse
 
andrekovac profile image
André Kovac

Thanks for nicely summarizing the essence of the article 🎉

Collapse
 
caladbol profile image
Info Comment hidden by post author - thread only accessible via permalink
Thaynã Ferreira de Sousa

Considering you've copied almost word by word from another external blog post, the minimum you should've done is provide the link to the source from wich you copied the information.

For those interested, heres the link: benmvp.com/blog/conditional-react-...

Collapse
 
alais29dev profile image
Alfonsina Lizardo

Great tutorial, thanks a lot! I was battling with Typescript on how to accomplish this ❤️

Collapse
 
favouritejome profile image
Favourite Jome

Thanks for sharing this!

Some comments have been hidden by the post's author - find out more