TypeScript Version: 3.6.0-dev.20190726
Search Terms: narrowing instanceof
Code
class A {
constructor(public owner?: { node: A }) {}
};
function parentOfType(data: A, type: typeof A) {
let owner = data.owner;
while (owner && !(owner.node instanceof type)) {
// Error given below: Property 'owner' does not exist on type 'never'.
owner = owner.node.owner;
}
return owner;
}
Expected behavior: No errors.
Actual behavior:
owner.node within the whole loop is typed as never, even though the type of owner in the while loop is still reported as { node: A }. Meanwhile, the error goes away if I change the condition in the while loop to be:
while(owner && !(owner ? owner.node instanceof type : false))
Which is strange, because the ternary should always reach the true branch by virtue of the owner truthiness check immediately preceding, so it seems like these conditions should be identical. (Although maybe the extra layer of wrapping somehow resets the narrowing results?)
Playground Link: http://www.typescriptlang.org/play/index.html#code/MYGwhgzhAECC0G9oChpusA9gOwgFwCcBXYPTAgCgAciAjEAS2GkwHdsBTAgfgC5Fo2TABMO-eAF8AlIgkoJAbmTIAZkWykGOaFTAEO2PAHkVAFQCeVDhWFg8YcQBpoAa3BR+eSx0wq4MhFRoEA48FnYuaABeaFt7ADo2TgIlINYACwYQ6AokyIAyfOgAQlyIgnihUWgGXHsNHz83SAgpAKC0PIJo8OTKkQ5E8qU0CWQx1XVNbV19QxMLKwAmGzsHOGdmj2gvK19-RCCQsK6euLAh5NS0DKyOHNPCkrLk6G5ern7q2vwwBv2tjB+CowCAIBw2od0B9ujEul9Bl0RtAxhMgA
You just said node: A.
Then you somehow manage to say !(owner.node instanceof type).
So, if it is A and not A, it is a contradiction. And we get the never type.
So, if it is A and not A, it is a contradiction. And we get the never type.
That seems like it explains what the Typescript compiler is probably thinking. The problem is that it's not actually a contradiction. Consider:
class B extends A { }
declare const a: A;
// constructor function for B, as a subclass, satisfies typeof A,
// and yet the node in `a.owner.node` may well not be an instanceof B.
parentOfType(a, B);
Another workaround (if it matters) is to use a generic type to represent the instance of klass:
function parentOfType<T extends A>(data: A, klass: new (...args: any) => T) {
let owner = data.owner;
while (owner && !(owner.node instanceof klass)) {
owner = owner.node.owner;
}
}
This has the possible advantage of being closer to what you mean (after all, if T is exactly A then that's tantamount to the contradiction mentioned above... give or take complications) although one might argue it shouldn't act differently from the concrete version using A instead of T.
Thanks @jcalz. I did actually have a generic in my original version, but I did it like this:
function parentOfType<T extends typeof A>(data: A, klass: T)
That signatures still produces the error though (and I think introducing the parameter is just redundant). My guess is that maybe the error here has something to do with nominal typing rules applied for class constructor function types, and presumably for instanceof narrowing, which makes them behave differently than something like new (...args: any) => T, but idk...
Strong feedback from users was that given something like
function fn(x: SomeClass | string) {
if (x instanceof SomeClass) {
// ...
} else {
// what is 'x' here?
}
}
that x should be type string in the else branch. That's the behavior you're seeing here - instanceof will narrow away the return type of the constructor function of the RHS in the else case.
@RyanCavanaugh That behavior makes sense to me. But I think the difference is that, in your code snippet, the RHS of the instanceof check is hardcoded to be the parent class. So, even if x is actually an instance of a subclass of SomeClass, the narrowing will still work correctly. In my case, because the instanceof could be checking against the subclass constructor, it seems more analogous to this code:
class A {}
class B extends A {}
function fn(x: A | string) {
if(!(x instanceof B)) {
// Ts does the "correct" thing and leaves x here as A | string
}
}
It would be nice if TS could recognize that the klass variable, as typeof A, could be B and narrow like the above. Maybe the correct behavior would be to compute the results of narrowing as if klass where each subtype of typeof A, and then union those? Although that seems like a lot of work...
It would be nice if TS could recognize that the klass variable, as typeof A, could be B and narrow like the above.
Oh wait... I imagine that, from the type checker's perspective, the type of the RHS is going to be typeof A whether the variable on the right is klass or is actually the A constructor referenced directly. So maybe those two cases are impossible to distinguish. But perhaps this could work with the type parameter from my earlier example? i.e., when klass is:
function parentOfType<T extends typeof A>(data: A, klass: T)
Oh wait... I imagine that, from the type checker's perspective, the type of the RHS is going to be typeof A whether the variable on the right is klass or is actually the A constructor referenced directly
This is exactly correct.
The idea to treat a constrained type parameter differently is interesting, but would likely be a breaking change in other working code. If you wanted to put up a PR to see the effects we might consider it, but I don't think it's a bug per se.
The idea to treat a constrained type parameter differently is interesting, but would likely be a breaking change in other working code. If you wanted to put up a PR to see the effects we might consider it, but I don't think it's a bug per se.
Ok, thanks. I doubt I'll have time to work on a PR soon, but I'll keep it in mind/on the backburner should I have time to dive into the TS code base later.
Most helpful comment
That seems like it explains what the Typescript compiler is probably thinking. The problem is that it's not actually a contradiction. Consider: