When type checking is performed through type guard, it become never
type outside the conditional statement.
You can test here
class Fruite {
num: number;
constructor(num: number) {
this.num = num;
}
}
class Apple extends Fruite {
constructor(num: number) {
super(num);
}
}
class Banana extends Fruite {
constructor(num: number) {
super(num);
}
}
const items = [new Apple(1), new Banana(10)];
const clone = (items: Fruite[]) => {
return items.map(item => {
if (item instanceof Apple) {
return new Apple(item.num);
} else if (item instanceof Banana) {
return new Banana(item.num); // works well
}
return new Banana(item.num); // works well
});
}
// ---- ERROR PART ----
const isApple = (item: Fruite): item is Apple => {
return item instanceof Apple;
}
const isBanana = (item: Fruite): item is Banana => {
return item instanceof Banana;
}
const clone2 = (items: Fruite[]) => {
return items.map(item => {
if (isApple(item)) {
return new Apple(item.num);
} else if (isBanana(item)) {
return new Banana(item.num); // item is never !
}
return new Banana(item.num); // item is never !
});
}
Why does the typescript determine the item as never
?
CodePudding user response:
The problem is that Apple
and Banana
are structurally identical, and since the TypeScript type system is largely structural and not nominal, it considers the two types equivalent. That is, the fact that Apple
and Banana
have two different declarations does not mean that TypeScript considers them to be different types. So if you write code where types are checked structurally, the compiler will decide that if something is an Apple
then it must be a Banana
and vice versa.
In your clone
function, you are directly performing an instanceof
type guard check. And instanceof
narrowing compares types nominally and not structurally, because that's usually what people want.
On the other hand, when you refactored this check into user-defined type guard functions isApple()
and isBanana()
, such nominal narrowing only happens inside the bodies of these functions. The return type of the function, item is Apple
or item is Banana
doesn't convey any such special nominal flavor to the callers, so the type checker does the standard structural type check. And so, inside clone2
, the compiler thinks it is impossible for item
to be a Banana
if it is not an Apple
. And you get an error.
(Yes, these two forms of type checking aren't fully consistent with each other. This is intentional; see microsoft/TypeScript#33481 for more information.)
Anyway, generally speaking, you'll have better results if you make sure any two types that are meant to be different should differ structurally, by adding differing members to them. For example:
class Apple extends Fruite {
readonly type = "Apple";
constructor(num: number) {
super(num);
}
}
class Banana extends Fruite {
readonly type = "Banana";
constructor(num: number) {
super(num);
}
}
Now both classes have a type
property of differing string literal type. This clears up the problem with clone2()
:
const clone2 = (items: Fruite[]) => {
return items.map(item => {
if (isApple(item)) {
return new Apple(item.num);
} else if (isBanana(item)) {
return new Banana(item.num); // okay
}
return new Banana(item.num); // okay
});
}
Again, just about any distinguishing property will work. For classes you can add a private
or protected
member and as long as they have separate declarations the classes will be considered distinct (that is, private
/protected
properties are compared nominally and not structurally):
class Apple extends Fruite {
private prop: undefined;
constructor(num: number) {
super(num);
}
}
class Banana extends Fruite {
private prop: undefined;
constructor(num: number) {
super(num);
}
}
It's up to you how you want to distinguish your classes. Ideally they would actually have distinct structures naturally, because you're using them for different purposes. Maybe a Banana
has a ripeness
property and a removeFromBunch()
method, while an Apple
has a variety
property and a keepTheDoctorAway()
method. But that depends on your use cases.