Search code examples
reactjstypescriptunion-types

Typescript - how to combine Union and Intersection types


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


Solution

  • 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>;