TypeScript Version: 3.3.3333
Search Terms:
typescript type guard else never
Code
enum Type { N, S }
interface Interface<E extends Type | unknown = unknown> {
type?: Type
data?: // just for an idea of how the E parameter is used
E extends Type.N ? number :
E extends Type.S ? string :
number | string
}
const isN = (i: Interface): i is Interface<Type.N> => i.type === Type.N
const i: Interface<unknown> = {}
if (isN(i)) {
// i is Interface<Type.N>
} else {
// i is never
// shouldn't it stay Interface<unknown>?
i
}
Related Issues:
https://github.com/microsoft/TypeScript/issues/17789
https://github.com/Microsoft/TypeScript/issues/10934
The value v is narrowed on assignment, so while it is declared has having type Type, it actually has type number. From that it follows that isN(v) is always true and never false, hence the narrowed type you see.
If you adjust to declaration of v to prevent narrowing you get the expected behaviour.
const v: Type = 42 as Type;
if (isN(v)) {
const n: number = v;
} else {
const s: string = v;
}
Note that the complex types here are just a distraction; it reproduces fine with const v: string | number = 42
Thanks for the quick response, guys! Unfortunately, I tried to create a minimal example based on a much more complex code, where the issue has originated, I guess it became too simple to reproduce ) I've updated the example with a little bit more complexity.
Ah ok. Yes I think something is not right here. The checker thinks that Interface<unknown> is a subtype of Interface<Type.N> which is why it's getting filtered from the type in the else clause.
const i: Interface<unknown> = { data: "hello" };
let j: Interface<Type.N> = { data: 3 }
j.data = i.data; // error
j = i; // no error
This is likely related to #31295 because you get the correct behaviour if you use two identical but differently named aliases.
enum Type { N, S }
interface Interface<E extends Type | unknown = unknown> {
type?: Type
data?: // just for an idea of how the E parameter is used
E extends Type.N ? number :
E extends Type.S ? string :
number | string
}
interface Interface2<E extends Type | unknown = unknown> {
type?: Type
data?: // just for an idea of how the E parameter is used
E extends Type.N ? number :
E extends Type.S ? string :
number | string
}
const isN = (i: Interface): i is Interface2<Type.N> => i.type === Type.N;
const i: Interface<unknown> = { data: "hello" };
let j: Interface2<Type.N> = { data: 3 }
j.data = i.data; // error
j = i; // error
if (isN(i)) {
i // i is Interface2<Type.N>
} else {
i // i is Interface<unknown>
}
This is an effect of the type checker deciding that Interface<E> bi-variant in E (because of the way E is witnessed only in the test of a conditional type). We measure variance in order to avoid repeated structural type checks, but there are known limitations around non-linear types such as the conditional type in the example. So, this is basically a design limitation.
You could consider including an optional property that witnesses E in the interface:
interface Interface<E = unknown> {
// ...
__E__?: E;
}
This causes the type checker to measure the interface as co-variant in E and you then get the expected behavior.
Awesome, @ahejlsberg, thanks for your help!
Most helpful comment
The value
vis narrowed on assignment, so while it is declared has having typeType, it actually has typenumber. From that it follows thatisN(v)is always true and never false, hence the narrowed type you see.If you adjust to declaration of
vto prevent narrowing you get the expected behaviour.