[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 ) => {
}} />
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.