Typescript - how to combine Union and Intersection types

Viewed 2232

I have the following component:

export enum Tags {
  button = 'button',
  a = 'a',
  input = 'input',
}

type ButtonProps = {
  tag: Tags.button;
} & ({ a?: string; b?: undefined } | { a?: undefined; b?: string }) &
  JSX.IntrinsicElements['button'];

type AnchorProps = {
  tag: Tags.a;
} & ({ a?: string; b?: undefined } | { a?: undefined; b?: string }) &
  JSX.IntrinsicElements['a'];

type InputProps = {
  tag: Tags.input;
} & ({ a?: string; b?: undefined } | { a?: undefined; b?: string }) &
  JSX.IntrinsicElements['input'];

type Props = ButtonProps | AnchorProps | InputProps;

const Button: React.FC<Props> = ({ children, tag }) => {
  if (tag === Tags.button) {
    return <button>{children}</button>;
  }
  if (tag === Tags.a) {
    return <a href="#">{children}</a>;
  }
  if (tag === Tags.input) {
    return <input type="button" />;
  }
  return null;
};

// In this instance the `href` should create a TS error but doesn't...
<Button tag={Tags.button} href="#">Click me</Button>

// ... however this does
<Button tag={Tags.button} href="#" a="foo">Click me</Button>

This has been stripped back a little to be able to ask this question. The point is I am attempting a Discriminated Union along with intersection types. I am trying to achieve the desired props based on the tag value. So if Tags.button is used then JSX's button attributes are used (and href in the example above should create an error as it is not allowed on button element) - however the other complexity is I would like either a or b to be used, but they cannot be used together - hence the intersection types.

What am I doing wrong here, and why does the type only work as expected when adding the a or b property?

Update

I've added a playground with examples to show when it should error and when it should compile.

playground

3 Answers

In your example there are 2 problems that have to be solved and both stem from the same "issue" (feature).

In Typescript, the following doesn't work as we would sometimes want:

interface A {
  a?: string;
}

interface B {
  b?: string;
}

const x: A|B = {a: 'a', b: 'b'}; //works

What you want is to explicitly exclude B from A, and A from B - so that they can't appear together.

This question discusses the "XOR"ing of types, and suggests using the package ts-xor, or writing your own. Here's the example from an answer there (same code is used in ts-xor):

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

Now, with this we can finally solve your problem:

interface A {
  a?: string;
}

interface B {
  b?: string;
}

interface C {
  c?: string;
}

type CombinationProps = XOR<XOR<A, B>, C>;

let c: CombinationProps;
c = {}
c = {a: 'a'}
c = {b: 'b'}
c = {c: 'c'}
c = {a: 'a', b: 'b'} // error
c = {b: 'b', c: 'c'} // error
c = {a: 'a', c: 'c'} // error
c = {a: 'a', b: 'b', c: 'c'} // error

More specifically, your types will be:

interface A {a?: string;}
interface B {b?: string;}

type CombinationProps = XOR<A, B>;

type ButtonProps = {tag: Tags.button} & JSX.IntrinsicElements['button'];
type AnchorProps = {tag: Tags.a} & JSX.IntrinsicElements['a'];
type InputProps = {tag: Tags.input} & JSX.IntrinsicElements['input'];

type Props = CombinationProps & XOR<XOR<ButtonProps,AnchorProps>, InputProps>;

You have to make a and b not optional, but instead use a union type (|) which corresponds to inclusive OR. It is intersected with the non-conditional properties (never). So now ts can correctly differentiate between them: Update

type ButtonProps = {
  tag: Tags.button;
  a?: string;
  b?: string
} & 
  JSX.IntrinsicElements['button'];

Here is a playground.

Usually I use the following technique to create generic functional components but it works for your case as well. The trick is to declare the component as function rather than const to be able to make it generic. That allows for props to be generic which in turn allows you to do:

export enum Tags {
    button = 'button',
    a = 'a',
    input = 'input',
  }

  type Props<T extends Tags = Tags> = JSX.IntrinsicElements[T] & {
    tag: T;
  } & ({ a?: string; b?: never } | { a?: never; b?: string });

  function Button<T extends Tags>({ children, tag }: Props<T>) {
    if (tag === Tags.button) {
      return <button>{children}</button>;
    }
    if (tag === Tags.a) {
      return <a href="#">{children}</a>;
    }
    if (tag === Tags.input) {
      return <input type="button" />;
    }
    return null;
  }

  // These should error due to href not being allowed on a button element
  const a = <Button tag={Tags.button} href="#" a="foo">Click me</Button>
  const b = <Button tag={Tags.button} href="#">Click me</Button>

  // These should work
  const c = <Button tag={Tags.button} a="foo">Click me</Button>
  const d = <Button tag={Tags.button} b="foo">Click me</Button>
  const e = <Button tag={Tags.button}>Click me</Button>

  // This should error as `a` and `b` can't be used together
  const f = <Button tag={Tags.button} a="#" b='a'>Click me</Button>

The only drawback is that you can't directly type the Button component, you can't say function Button<T> is a React.FC<Props<T>>, you can only type its props and return type.

Check the playground here (I left your examples at the bottom)

EDIT I updated the code with examples from your question and fixed the playground link

Related