Home > Net >  Either type containing different types
Either type containing different types

Time:10-21

[Original post edited to simplified example]

I want to create a component that will take one pair of values (both values based on the same type -> value, setValue), and, based on it, will run functions depending on those types. To fully illustrate the situation:

AnimalAndCatTypes.ts:

export interface Animal {
  type: string,
  age: number
}

export interface Cat extends Animal {
  typeOfCat: string,
}

ChangeSpecies.tsx:

import React from "react";
import { Animal, Cat } from "./AnimalAndCatTypes";

interface AnimalVersion {
  value: Animal;
  setValue: React.Dispatch<React.SetStateAction<Animal>>;
}

interface CatVersion {
  value: Cat;
  setValue: React.Dispatch<React.SetStateAction<Cat>>;
}

type Props = AnimalVersion | CatVersion;

const ChangeSpecies = (props: Props): JSX.Element => {
  const { value, setValue } = props;

  const handleChange = (data: string) => {
    setValue({ ...value, type: data });
  };

  return (
    <form>
      <label>
        <input
          type="text"
          value={value.type}
          onChange={(data) => handleChange(data.target.value)}
        />
      </label>
    </form>
  );
};

export default ChangeSpecies;

ExampleComponent.tsx:

import React, { useState } from "react";
import { Animal, Cat } from "./AnimalAndCatTypes";
import ChangeSpecies from "./ChangeSpecies";

const ExampleComponent = (): JSX.Element => {
  const [animal, setAnimal] = useState<Animal>({
    type: "dog",
    age: 2
  });
  const [cat, setCat] = useState<Cat>({
    type: "cat",
    age: 1,
    typeOfCat: "persian"
  });

  return (
    <div>
      <ChangeSpecies value={animal} setValue={setAnimal} />
      <ChangeSpecies value={cat} setValue={setCat} />
    </div>
  );
};

export default ExampleComponent;

The problem is in setValue where the typescript says that there can't be Animal used in React.Dispatch<React.SetStateAction<Cat>> because of missing typeOfCat.

How to create a type in which ts will know that Props are either Cat or Animal when creating component by checking that value and setValue are from one pair. Later, depending on that pair of types, will take the proper setValue type which is linked to value type and cannot mix with another?

[Added in edit]

Link to reproduction: https://codesandbox.io/s/zealous-mirzakhani-1x4kz?file=/src/App.js

CodePudding user response:

Main problem is in this union:

type Props = AnimalVersion | CatVersion

Because Props is a union, setValue is also a union of React.Dispatch<React.SetStateAction<Animal>> | React.Dispatch<React.SetStateAction<Cat>>.

According to the docs:

Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:

It means, if you want to call such function, arguments of all functions in a union will merge (intersect), that why setValue expects React.SetStateAction<Animal> & React.SetStateAction<Cat>.

Please see simple example:

declare var foo: (val: { name: string }) => void
declare var bar: (val: { surname: number }) => void

declare var union:typeof foo | typeof bar

// expects { name: string;} & { surname: number; }
union()

Most of the time, people are receiving never, because of this:

declare var foo: (val:  string) => void
declare var bar: (val:  number ) => void

declare var union:typeof foo | typeof bar

// expects never
union()

string & number -> produces never, because this type is unrepresentable.

In you case, setValue expects Cat, because Cat is a subtype of Animal. Intersection of supertype Animal and subtype Cat - gives you Cat type. You have an error, because setValue expects Cat whereas props.value might be an Animal, without extra typeOfCat type.

Now, when we know why you are getting intersection, we can proceed.

In order to fix it, you can create extra generic parameter for Animal|Cat:

import React from "react";

export interface Animal {
  type: string,
  age: number
}

export interface Cat extends Animal {
  typeOfCat: string,
}


interface AnimalVersion {
  value: Animal;
  setValue: React.Dispatch<React.SetStateAction<Animal>>;
}

interface CatVersion {
  value: Cat;
  setValue: React.Dispatch<React.SetStateAction<Cat>>;
}


type Props<T> = {
  value: T,
  setValue: React.Dispatch<React.SetStateAction<T>>;
}


const ChangeSpecies = <T extends Animal | Cat,>(props: Props<T>): JSX.Element => {
  const { value, setValue } = props;

  const handleChange = (data: string) => {
    setValue({ ...value, type: data });
  };

  return (
    <form>
      <label>
        <input
          type="text"
          value={value.type}
          onChange={(data) => handleChange(data.target.value)}
        />
      </label>
    </form>
  );
};

const jsx = <ChangeSpecies value={{ type: 'animal', age: 42 }} setValue={(elem /** elem is animal */) => {

}} />


const jsx2 = <ChangeSpecies value={{ type: 'animal', age: 42, typeOfCat: 'cat' }} setValue={(elem /**elem is infered as a cat */) => {

}} />

// error
const expectedError = <ChangeSpecies value={{ type: 'animal', typeOfCat: 'cat' }} setValue={(elem ) => {

}} />

Playground

Now, TS is aware that value has valid type for setValue. SInce, both types have type property, this line setValue({ ...value, type: data }); is valid

However, you should be aware about drawback of this solution:


type Props<T> = {
  value: T,
  setValue: React.Dispatch<React.SetStateAction<T>>;
}

type Result = Props<Animal | Cat>

const invalid: Result = {
  value: { type: 'animal', age: 42 },
  setValue: (elem /**React.SetStateAction<Animal | Cat> */) => {

  },
}

TS still think that elem is a union.

It means that if you provide explicit union generic during component construction:


const jsx = <ChangeSpecies<Animal|Cat> value={{ type: 'animal', age: 42 }} setValue={(elem) => {

}} />

TS will allow you to do it.


Second option

Safer solution is to use distributive-conditional-types:

type Props<T> = T extends any ? {
  value: T,
  setValue: React.Dispatch<React.SetStateAction<T>>;
} : never

type Result = Props<Animal | Cat>

In fact, this type is equal to your representation of Props union. It just written in more generic way.

If you want to stick with this solution, you should then create custom typeguard. It means it will affect your runtime. While, it is possible to write type guard for value, like isString or isNumer, it is hard to write typeguard for function. Because you can compare functions only by reference or arity.length. Hence, I think, it does not worth it.

  • Related