Mutually exclusive props in a React Component

Viewed 1065

How do I make sure my functions components props are mutually exclusive, with code completion?

My code:

type Variant =  'a' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'button'

interface TypographyProps {
    children: React.ReactNode | string
    className?: string | string[]
    variant?: Variant
}

type Text = TypographyProps
type Primary = TypographyProps & { primary: true }
type Secondary = TypographyProps & { secondary: true }
type Extra = TypographyProps & { extra: true }
type Dark = TypographyProps & { dark: true }
type Muted = TypographyProps & { muted: true }

function Typography(props: Text): JSX.Element;
function Typography(props: Primary): JSX.Element;
function Typography(props: Secondary): JSX.Element;
function Typography(props: Extra): JSX.Element;
function Typography(props: Dark): JSX.Element;
function Typography(props: Muted): JSX.Element;
function Typography({ children, className, variant, ...props }: TypographyProps): JSX.Element {

}

In WebStorm, when using control+spacebar (code completion), I don't get hints for props like primary or secondary. When I use more than one prop, I always get the error the first prop doesn't exist on type Muted.

e.g.

<Typography primary secondary>Foo</Typography>
3 Answers

Usually I consider a best practice to use only one props for exclusive parameters instead of multiple boolean props:


type Variant =  'a' | 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'button'

interface TypographyProps {
    children: React.ReactNode | string
    className?: string | string[]
    variant?: Variant
    type?: 'primary' | 'secondary' | 'extra' | 'dark' | 'muted'
}

function Typography({ children, className, variant, type, ...props }: TypographyProps): JSX.Element {

}

// e.g
<Typography variant="a" type="primary" />

I know it does not fully answer your question, but IMO it will reduce code complexity and allow better extensibility.

I think @SimonBruneaud's answer is the right way to go here. Overloads don't work well with JSX calls, or anything that needs to manipulate function types... the compiler tends to pretend that overloaded functions have just one call signature, usually either the first one or the last one. That's why you only get IntelliSense for most of the attributes.

You can use a single call signature whose props type is an exclusive-ish union of attributes. TypeScript doesn't actually allow you to prohibit keys, but you can make them optional with a property value of never or undefined. For example, {primary: true, secondary?: never} | {primary?: never, secondary: true} is a type that will accept either a value like {primary: true} or {secondary: true} but will reject a value like {primary: true, secondary: true}.

One way to do it looks like this:

type PropNames = 'primary' | 'secondary' | 'extra' | 'dark' | 'muted';
type JustOneProp = {
  [K in PropNames]:
  { [P in K]: true } & { [P in Exclude<PropNames, K>]?: never } extends
  infer O ? { [P in keyof O]: O[P] } : never
}[PropNames];
type NoProps = { [P in PropNames]?: never };

type MutuallyExclusiveProps = JustOneProp | NoProps;

You can see that JustOneProp requires exactly one of your properties to be true while the others are left out or undefined:

/* type JustOneProp = {
    primary: true;
    secondary?: undefined;
    extra?: undefined;
    dark?: undefined;
    muted?: undefined;
} | {
    secondary: true;
    primary?: undefined;
    extra?: undefined;
    dark?: undefined;
    muted?: undefined;
} | {
    extra: true;
    primary?: undefined;
    secondary?: undefined;
    dark?: undefined;
    muted?: undefined;
} | {
    ...;
} | {
    ...;
}
*/

Anyway, then Typography's call signature looks like this:

declare function Typography(props: TypographyProps & MutuallyExclusiveProps): JSX.Element;

Now you should get code completion, and the good things work:

{ <Typography primary>Foo</Typography> /* okay */ }

and the bad things break:

{ <Typography primary secondary>Foo</Typography> /* error!*/ }
// ~~~~~~~~~~ <-- types of property 'primary' are incompatible

although the error message might be a bit cryptic (it's saying that you have true where it's expecting undefined).


But this is probably not worth it, since it's much easier to just have an optional type argument like the answer I linked shows. I just wanted to demonstrate that what you're asking for can mostly be done.

Playground link to code

Just want to extend @jcalz's answer for general usage:

type MutuallyExclude<T, E extends keyof T> =
  | {
      [K in E]: { [P in K]: T[P] } & Omit<T, E> & {
          [P in Exclude<E, K>]?: never;
        } extends infer O
        ? { [P in keyof O]: O[P] }
        : never;
    }[E]
  | ({ [K in E]?: never } & Omit<T, E>);

And the usage would like:

interface TypographyProps {
  primary: true;
  secondary: true;
}
type ExclusiveProps = 'primary' | 'secondary';
const Typography = (props: MutuallyExclude<TypographyProps, ExclusiveProps>) => {
  ...
};

const Case1 = () => <Typography primary />; // fine
const Case2 = () => <Typography />; // fine
const Case3 = () => <Typography primary secondary />; // error

While the error message is hard to be understand. I will update the answer if I figure out a better solution, or maybe it will be complement in the comment.

If one of the properties must be assigned, the simplest generic type would be:

type MutuallyExclude<T, E extends keyof T> = {
  [K in E]: { [P in K]: T[P] } & {
    [P in Exclude<E, K>]?: never;
  } & Omit<T, E>;
}[E];

The usage is as same as above, but the second case will return error:

interface TypographyProps {
  primary: true;
  secondary: true;
}
type ExclusiveProps = 'primary' | 'secondary';
const Typography = (props: MutuallyExclude<TypographyProps, ExclusiveProps>) => {
  ...
};

const Case1 = () => <Typography primary />; // fine
const Case2 = () => <Typography />; // error
const Case3 = () => <Typography primary secondary />; // error

In my case, it is used for customized RadioGroup since I want it can be described by either options or children. While it would be pretty ambiguous when both of them are assigned.

Related