7

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

leepowell
  • 3,838
  • 8
  • 27
  • 35

3 Answers3

7

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>;
Marian
  • 3,789
  • 2
  • 26
  • 36
1

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.

Domino987
  • 8,475
  • 2
  • 15
  • 38
0

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

Ján Jakub Naništa
  • 1,880
  • 11
  • 12
  • Sorry, your playground link isn't displaying your example. – leepowell May 28 '20 at 08:25
  • @leepowell I fixed the playground but just in case also pasted the whole code with your examples in the answer – Ján Jakub Naništa May 28 '20 at 10:33
  • Thanks - would you mind explaining what the `T extends Tags = Tags` does? I understand the extending part, but am unsure of the `=` - I just modified your example to remove that and it still worked. – leepowell May 29 '20 at 12:43
  • Oh yes sorry not necessary - sometimes i want to only partially specify the type arguments and let TypeScript do the rest. If for example i have a type that takes two arguments and i only want to specify the first one I use = [something] to specify the default. Just like optional function parameters actually – Ján Jakub Naništa May 29 '20 at 14:23
  • Also a small note - I mentioned you can't directly type the `Button` function as `React.FC`. Just wanted to say there is a workaround should that be necessary but well I usually prefer not to use it. You can't type it directly BUT you can type a return value of an (unused) function like so: `const someUnusedFunction = (): React.FC> => Button;`. – Ján Jakub Naništa May 29 '20 at 14:34