I have a problem when I try to use instanceof
with derived class instances in a if-else statement. Consider the following example:
interface IBaseModel {
id: string
}
class BaseClass {
model: IBaseModel
constructor() {
}
setModel(model: IBaseModel) {
this.model = model
}
getValueByName(name: string) {
return this.model[name];
}
}
interface IDerived1Model extends IBaseModel {
height: number;
}
class Derived1 extends BaseClass {
setModel(model: IDerived1Model) {
super.setModel(model);
// Do something with model...
}
}
interface IDerived2Model extends IBaseModel {
width: number;
}
class Derived2 extends BaseClass {
setModel(model: IDerived2Model) {
super.setModel(model);
// Do something with model...
}
}
const model1 = { id: "0", height: 42 };
const model2 = { id: "1", width: 24 };
const obj1 = new Derived1();
obj1.setModel(model1);
const obj2 = new Derived2();
obj2.setModel(model2);
const objs: BaseClass[] = [
obj1,
obj2
];
let variable: any = null;
for (const obj of objs) {
if (obj instanceof Derived1) {
variable = obj.getValueByName("height"); // Ok, obj is now of type `Derived1`
} else if (obj instanceof Derived2) {
variable = obj.getValueByName("width"); // Does not compile: Property 'getValueByName' does not exist on type 'never'
}
console.log("Value is: " + variable);
}
Here, getValueByName
cannot be called on obj
in the else
part, as it was narrowed to never
. Somehow, Typescript thinks that the else
will never be executed, but it is wrong.
The important thing to look at is the overriding of the function setModel
. The overrides have different parameter types, but those types inherit from the base IBaseModel
type. If I change those to the base type, Typescript doesn't complain and compiles fine :
class Derived1 extends BaseClass {
setModel(model: IBaseModel) {
super.setModel(model);
// Do something with model...
}
}
class Derived2 extends BaseClass {
setModel(model: IBaseModel) {
super.setModel(model);
// Do something with model...
}
}
So my question is, why does having overrides with different types make the instanceof
operator narrow the type of the object to never
? Is this by design?
This was tested with Typescript 2.3.4, 2.4.1 and in the Typescript Playground.
Thanks!
Welcome to the world of TypeScript Issue #7271! You've been bitten by TypeScript's structural typing and its strange (and frankly unsound) interactions with instanceof
.
TypeScript sees Derived1
and Derived2
as exactly the same type, because they have the same structural shape. If obj instanceof Derived1
returns false
, the TypeScript compiler thinks both "Okay, obj
is not a Derived1
" and "Okay, obj
is not a Derived2
", since it doesn't see a difference between them. And then when you check for obj instanceof Derived2
returning true
, the compiler says "Gee, obj
both is and is not a Derived2
. That can never
happen." Of course there is a difference between Derived1
and Derived2
at runtime, and that can happen. Which is your problem.
The solution: shove some differing property into Derived1
and Derived2
so that TypeScript can tell the difference between them. For example:
class Derived1 extends BaseClass {
type?: 'Derived1'; // add this line
setModel(model: IDerived1Model) {
super.setModel(model);
// Do something with model...
}
}
class Derived2 extends BaseClass {
type?: 'Derived2'; // add this line
setModel(model: IDerived2Model) {
super.setModel(model);
// Do something with model...
}
}
There's now an optional type
property on each class with a different string literal type (without changing the emitted JavaScript). TypeScript now realizes that Derived1
is not the same as Derived2
and your error goes away.
Hope that helps. Good luck!
@sebastien-grenier said:
Thanks for the explanation! However, I fail to see why Typescript considers them structurally identical when the types of the parameter in the override is different, but everything compiles fine when the type is identical (i.e. the same as the parent,
IBaseModel
). Also, what happens if I already have a member calledtype
on my object? Can it conflict withtype
? ?. Thanks!
Wow, that is strange. It looks like there was a change (#10216) at some point to fix some instances of issue #7271, but you managed to find a new one. My guess is that because you override the setModel
method with a narrower argument type (which is unsound, by the way... every BaseClass
should have a setModel()
that accepts any IBaseModel
. If you're interested in doing this soundly we can talk), it fools the code change in #10216 into not applying. This might be a bug... you may want to file it.
Yes, if you already have a property with the same key you should pick a new one. The idea is to brand the types. You can pick a name like __typeBrand
if you're worried about accidental conflict.
But there is a more straightforward change you could do which would not conflict:
class Derived1 extends BaseClass {
model: IDerived1Model;
// your overrides follow
}
class Derived2 extends BaseClass {
model: IDerived2Model;
// your overides follow
}
Presumably you want each class to know that its model
is the narrowed type, right? So doing the above narrowing of model
both lets the compiler know the types are structurally distinct and makes the derived classes safer to use.
Cheers!