instanceof narrowing should preserve generic types from super to child type
See original GitHub issueSearch Terms
- instanceof generic
- narrow instanceof
Suggestion
The following code has a rather unexpected behavior today:
class Parent<T> {
x: T;
}
class Child<S> extends Parent<S> {
y: S;
}
function example(obj: Parent<number>) {
if (obj instanceof Child) {
const child = obj;
}
}
The narrowed type that child
gets is Child<any>
.
I’m requesting that the type instead gets narrowed to Child<number>
, which is what I originally assumed would happen.
My proposal is, in general, to infer the type arguments to the narrowed-to class type whenever they can be determined from the original type.
Precise Behavior
Consider the following code today:
const x: P = ...;
if (x instanceof C) {
x;
}
Today, if C
is a generic class, x
gets the narrowed type C<any, any, any, ...>
; otherwise it just gets the type C
.
My proposal is to consider the following piece of code, and use it to inform
Imagine that C
has a no-argument constructor. Then the following piece of code is valid today:
function onlyP(c: P) { ... }
onlyC(new C());
in order to make it valid, the compiler infers type arguments for C
that make it into a subtype of P
. My proposal is to use the same strategy to infer the type arguments for C
in an instanceof
narrowing.
Consider the following examples today, and their corresponding instanceof
narrowings:
function ex1(x: Parent<string>) { }
ex1(new Child()); // inferred arguments: <string>
function ex2(x: Parent<number | string>) { }
ex2(new Child()); // inferred arguments: <number | string>
function ex3(x: Parent<number> | string) { }
ex3(new Child()); // inferred arguments: <number>
function ex4(x: Parent<number> | Parent<string>) { }
ex4(new Child()); // inferred arguments: Child<number | string>
// Note: the above errors, because it Child<number|string> actually fails to be a subtype of
// Parent<number> | Parent<string>.
// We can either choose to infer Child<any> in this case, or use the (incorrect, but more-precise)
// inference Child<number | string>.
Examples
The original use-case I had in mind was roughly the following:
abstract class Obtainer<T> {
__phantom: T = null as T;
}
abstract class Fetcher<T> extends Obtainer<T> {
public abstract fetch(): Promise<T>;
}
abstract class Dependency<T, D> extends Obtainer<T> {
public abstract dependencies(): Obtainer<D>;
public abstract compute(got: D): T;
}
async function obtain<T>(obtainer: Obtainer<T>): Promise<T> {
if (obtainer instanceof Fetcher) {
// obtainer: Fetcher<T>
// currently, it's a Fetcher<any>
return await obtainer.fetch();
}
if (obtainer instanceof Dependency) {
// obtainer: Dependency<T, any>
// currently, it's a Dependency<any, any>
const dependencies = obtainer.dependencies();
return obtainer.compute(await obtain(dependencies));
}
throw new Error("not implemented");
}
(note: there’s still one extraneous any
in the above, since the D
parameter cannot be inferred)
Checklist
My suggestion meets these guidelines:
- This wouldn’t be a breaking change in existing TypeScript/JavaScript code
- This is a breaking change. However, I think it could only really break code that was already broken. If really necessary, it could be put behind a new
--strictGenericNarrowing
flag.
- This is a breaking change. However, I think it could only really break code that was already broken. If really necessary, it could be put behind a new
- This wouldn’t change the runtime behavior of existing JavaScript code
- No change to runtime; just inferred generic types.
- This could be implemented without emitting different JS based on the types of the expressions
- [ x This isn’t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
- This feature would agree with the rest of TypeScript’s Design Goals.
- I think this is an easy win for a more sound type system without any negative impact
Also, I am interested in contributing this change if it’s approved!
Issue Analytics
- State:
- Created 5 years ago
- Reactions:3
- Comments:12 (7 by maintainers)
Top GitHub Comments
@thw0rted I agree that it’s very counterintuitive, but it turns out that the types do overlap in this case. The reason is that there appears to be a chain of legal subtype casts that make this happen.
Here’s an example of an instance of
Child
, stripping away the prototype, constructor, methods, etc., which could affect variance but we’ll pretend everything is largely covariant here:{ t: 'hi', a: 'bye', b: 5 }
. This clearly “is” both aChild<string | number>
and also aParent<string, number>
and also aParent<string | number, string | number>
, because it has all of the properties that each of those types require, which is all we really mean when we say “x
has typeT
”.It also turns out in this case that the most general sound type we can assign says exactly that: it is both a
Child<string|number>
and also aParent<string, number>
, so it is aChild<string|number> & Parent<string, number>
. The type annotations tell us what we’re allowed to do with an object; so there’s not always some “best” type (e.g. is a{x: 4, y: "hi"}
an{x:number,y:string}
or is it a{x:4,y:"hi"}
or is a{x:string|number,y:string|number}
? The answer is: it’s essentially all of them at once. The annotation tells you what you can do with it and what you can expect from it, but not what it is.The benefits of a fully general existential type approach is that you’d be able to largely side-step these complexities. You’d immediately be able to narrow it down to something like
Parent<string, number> & exists<T where Child<T> extends Parent<string, number>> Child<T>
which is crude and complicated looking, but definitely correct.Then you’d have the task of trying to simplify away that scary-looking type, which hopefully could be built on some nice theory instead of ad-hoc patches to inference.
Thanks Nathan, I hadn’t considered the case of a union type for
T
but in hindsight it’s obvious.