Conditionally required props with Typescript in React

November 2, 2020

Typescript can be really powerful way of enhancing your react components to ensure the correct props are provided with expected values. Sometimes however you may want some props to required based on what other props have been provided!

Let's dive into an example:

const Image = ({ src, width, height, layout, ...rest }) => (
  <img
    src={src}
    width={width}
    height={height}
    className={`layout-${layout}`}
    {...rest}
  />
);

Now, our layout prop here might only be able to accept certain values. For example: responsive or fill. So let's convert this example into Typescript:

import React, { FC } from 'react';

type Layout = 'responsive' | 'fill';

type ImageProps = Omit<JSX.IntrinsicElements['img'], 'src' | 'width' | 'height'> & {
  src: string;
  width: string | number;
  height: string | number;
  layout: Layout;
};

const Image: FC<ImageProps> = ({
  src, width, height, layout, ...rest
}) => (
  <img
    src={src}
    width={width}
    height={height}
    className={`layout-${layout}`}
    {...rest}
  />
);

export default Image;

So now we have correct typed the correct values, what if we wanted to enforce a rule that when layout="fill" is provided you are NOT allowed to define the width or height props. But when layout="responsive" is provided you MUST set them.

Well, to do that we can change our ImageProps type to support exactly this.

type ImageProps = Omit<JSX.IntrinsicElements['img'], 'src' | 'width' | 'height'> & {
  src: string;
} & ({
  layout: 'fill';
  width?: never;
  height?: never;
} | {
  layout?: Exclude<Layout, 'fill'>;
  width: string | number;
  height: string | number;
});

Let's break this down slightly:

Using the Omit utility type we are retrieving all the props that an img element can have, then omitting the src, width and height props. This allows us to redefine them in our type.

Omit<JSX.IntrinsicElements['img'], 'src' | 'width' | 'height'>

Next up we define the src prop as a required string value. The & will merge this with the previous type.

& {
  src: string;
}

Next up is where the magic happens. We're defining a conditional type, where we're saying that when layout is provided with the 'fill' value it will use the first. Otherwise it'll use the second type.

The Exclude utility type is similar to Omit.

& ({
  layout: 'fill';
  width?: never;
  height?: never;
} | {
  layout?: Exclude<Layout, 'fill'>;
  width: string | number;
  height: string | number;
})

Below is the completed example. You'll notice there is also the toInt function in order to catch when width or height is the never type instead of passing it directly into the img component.

import React, { FC } from 'react';

function toInt(value: unknown): number | undefined {
  if (typeof value === 'number') {
    return value;
  }

  if (typeof value === 'string') {
    return parseInt(value, 10);
  }

  return undefined;
}

type Layout = 'responsive' | 'fill';

type ImageProps = Omit<JSX.IntrinsicElements['img'], 'src' | 'width' | 'height'> & {
  src: string;
} & ({
  layout: 'fill';
  width?: never;
  height?: never;
} | {
  layout?: Exclude<Layout, 'fill'>;
  width: string | number;
  height: string | number;
});

const Image: FC<ImageProps> = ({
  src, width, height, layout, ...rest
}) => {
  const widthInt = toInt(width);
  const heightInt = toInt(height);
  return (
  <img
    src={src}
    width={widthInt}
    height={heightInt}
    className={`layout-${layout}`}
    {...rest}
  />);
}

export default Image;

I found this pattern whilst exploring NextJS' Image component: https://github.com/vercel/next.js/blob/canary/packages/next/client/image.tsx, I have attempted to simplify the example here for demonstration purposes.

In this post I also touched on Utility Types (Exclude and Omit specifically). If you'd like to learn more about them you can see the Typescript documentation: https://www.typescriptlang.org/docs/handbook/utility-types.html