Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 910
leepowell Avatar asked May 24 '20 09:05

leepowell


People also ask

How do I assign two TypeScript types?

TypeScript allows you to define multiple types. The terminology for this is union types and it allows you to define a variable as a string, or a number, or an array, or an object, etc. We can create union types by using the pipe symbol ( | ) between each type.

When would you use an intersection type instead of a union type?

Intersection types are closely related to union types, but they are used very differently. An intersection type combines multiple types into one. This allows you to add together existing types to get a single type that has all the features you need.

How do you handle a union type in TypeScript?

TypeScript Union Type Narrowing To narrow a variable to a specific type, implement a type guard. Use the typeof operator with the variable name and compare it with the type you expect for the variable.

What is an intersection type in TypeScript?

An intersection type creates a new type by combining multiple existing types. The new type has all features of the existing types. To combine types, you use the & operator as follows: type typeAB = typeA & typeB; Code language: TypeScript (typescript)


Video Answer


2 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>;
like image 132
hlfrmn Avatar answered Oct 22 '22 13:10

hlfrmn


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.

like image 1
Domino987 Avatar answered Oct 22 '22 14:10

Domino987