Typescript type narrowed to never with instanceof in an if-else statement
Asked Answered
V

1

7

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!

Vaginectomy answered 28/7, 2017 at 19:33 Comment(0)
C
5

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!


Update 1

@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 called type on my object? Can it conflict with type? ?. 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!

Cupule answered 29/7, 2017 at 0:46 Comment(3)
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 called type on my object? Can it conflict with type? ?. Thanks!Vaginectomy
I've submitted a bug here: github.com/Microsoft/TypeScript/issues/17612. Thanks for the update! And yes, your example is exactly what I want to do. Thanks!Vaginectomy
I've never been so warmly welcomed into a world of pain. Thanks for the write-up, though.Steato

© 2022 - 2024 — McMap. All rights reserved.