-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
TS doesn't narrow generic parameter T to null, despite an explicit comparison #57803
Comments
This is sort of a duplicate of #13995 except that that is closed because it's largely fixed. Note that the fact that function isNullBad<T>(value: T) {
if (value === null) {
value
// ^? (parameter) value: T // this is okay
const v: null = value; // error, this is not
}
return value === null;
} #13995 was fixed by #43183 and specifically only for the case where the generic type parameter is constrained to a union. So if you want to work around it today you could constrain function isNull<T extends null | {} | undefined>(value: T) {
if (value === null) {
value // still T
// ^? (parameter) value: T extends null | {} | undefined // this is still okay
const v: null = value; // okay now also
}
return value === null;
} |
The negated branch gets nicely narrowed down. The observed behavior is quite surprising here (TS playground): function acceptNull(arg: null) {}
function acceptNonNull(arg: {} | undefined) {}
function test<T>(value: T) {
if (value === null) {
value;
// ^? (parameter) value: T
acceptNull(value) // error
} else {
value;
// ^? (parameter) value: T & ({} | undefined)
acceptNonNull(value) // ok
}
} |
There are 2 mechanisms at play here and in related cases.
Fixing it here, in a somewhat trivial way, has almost not downsides: git diffdiff --git a/src/compiler/checker.ts b/src/compiler/checker.ts
index c8eefa7778..620f0ffe49 100644
--- a/src/compiler/checker.ts
+++ b/src/compiler/checker.ts
@@ -26986,6 +26986,10 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
const reduced = recombineUnknownType(getTypeWithFacts(strictNullChecks && type.flags & TypeFlags.Unknown ? unknownUnionType : type, facts));
if (strictNullChecks) {
switch (facts) {
+ case TypeFacts.EQUndefined:
+ return mapType(reduced, t => getIntersectionType([t, undefinedType]));
+ case TypeFacts.EQNull:
+ return mapType(reduced, t => getIntersectionType([t, nullType]));
case TypeFacts.NEUndefined:
return removeNullableByIntersection(reduced, TypeFacts.EQUndefined, TypeFacts.EQNull, TypeFacts.IsNull, nullType);
case TypeFacts.NENull:
diff --git a/tests/baselines/reference/unknownControlFlow.types b/tests/baselines/reference/unknownControlFlow.types
index 7b320964b8..43cef8a6a2 100644
--- a/tests/baselines/reference/unknownControlFlow.types
+++ b/tests/baselines/reference/unknownControlFlow.types
@@ -214,56 +214,56 @@ function f21<T>(x: T) {
}
else {
x; // T
->x : T
+>x : T & undefined
}
if (x !== null) {
>x !== null : boolean
->x : T
+>x : (T & ({} | null)) | (T & undefined)
x; // T & ({} | undefined)
->x : T & ({} | undefined)
+>x : (T & {}) | (T & undefined)
}
else {
x; // T
->x : T
+>x : T & null
}
if (x !== undefined && x !== null) {
>x !== undefined && x !== null : boolean
>x !== undefined : boolean
->x : T
+>x : (T & null) | (T & {}) | (T & undefined)
>undefined : undefined
>x !== null : boolean
->x : T & ({} | null)
+>x : (T & null) | (T & {})
x; // T & {}
>x : T & {}
}
else {
x; // T
->x : T
+>x : (T & null) | (T & undefined)
}
if (x != undefined) {
>x != undefined : boolean
->x : T
+>x : (T & null) | (T & {}) | (T & undefined)
>undefined : undefined
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
else {
x; // T
->x : T
+>x : (T & null) | (T & undefined)
}
if (x != null) {
>x != null : boolean
->x : T
+>x : (T & null) | (T & {}) | (T & undefined)
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
else {
x; // T
->x : T
+>x : (T & null) | (T & undefined)
}
}
@@ -281,14 +281,14 @@ function f22<T extends {} | undefined>(x: T) {
}
else {
x; // T
->x : T
+>x : T & undefined
}
if (x !== null) {
>x !== null : boolean
->x : T
+>x : (T & {}) | (T & undefined)
x; // T
->x : T
+>x : (T & {}) | (T & undefined)
}
else {
x; // T
@@ -297,7 +297,7 @@ function f22<T extends {} | undefined>(x: T) {
if (x !== undefined && x !== null) {
>x !== undefined && x !== null : boolean
>x !== undefined : boolean
->x : T
+>x : (T & {}) | (T & undefined)
>undefined : undefined
>x !== null : boolean
>x : T & {}
@@ -307,30 +307,30 @@ function f22<T extends {} | undefined>(x: T) {
}
else {
x; // T
->x : T
+>x : T & undefined
}
if (x != undefined) {
>x != undefined : boolean
->x : T
+>x : (T & {}) | (T & undefined)
>undefined : undefined
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
else {
x; // T
->x : T
+>x : T & undefined
}
if (x != null) {
>x != null : boolean
->x : T
+>x : (T & {}) | (T & undefined)
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
else {
x; // T
->x : T
+>x : T & undefined
}
}
@@ -348,25 +348,25 @@ function f23<T>(x: T | undefined | null) {
}
if (x !== null) {
>x !== null : boolean
->x : T | null | undefined
+>x : (T & {}) | null | undefined
x; // T & {} | undefined
>x : (T & {}) | undefined
}
if (x != undefined) {
>x != undefined : boolean
->x : T | null | undefined
+>x : (T & {}) | null | undefined
>undefined : undefined
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
if (x != null) {
>x != null : boolean
->x : T | null | undefined
+>x : (T & {}) | null | undefined
x; // T & {}
->x : NonNullable<T>
+>x : T & {}
}
}
@@ -957,7 +957,7 @@ function doSomething1<T extends unknown>(value: T): T {
>undefined : undefined
return value;
->value : T
+>value : T & undefined
}
if (value === 42) {
>value === 42 : boolean However, it kinda creates some extra type identities and the control flow isn't able to "merge" those types back to just The other mechanism - the one that handles the case when |
This is a big deal, actually. If this invariant doesn't hold, then you get "spooky action at a distance" bugs where adding an Any fix here needs to not have downsides. I'd really prefer there be more concrete upsides too; at the point you've narrowed a value to a unit type, it's not clear why you'd write the identifier over the value literal (i.e. why not just write |
This issue results in some odd behavior with inferred type predicates: // β
returns x is T & ({} | undefined); good!
function isNonNull<T>(x: T) {
return x !== null;
}
// π returns boolean; would prefer x is T & null
function isNull<T>(x: T) {
return x === null;
}
// π€ returns x is T & null
function isNull2<T>(x: T) {
return (typeof x === "object" && x === null);
} in particular see this tweet for the source of this issue. I'd guess that checking for non-null/undefined is the more common case, so it's nice that type predicates are at least inferred in that direction. |
π Search Terms
T to null
π Version & Regression Information
β― Playground Link
https://www.typescriptlang.org/play?ts=5.5.0-pr-57465-110#code/GYVwdgxgLglg9mABDAzgORAG0wHgCoB8AFAG4CGmIApgFyJ4CUiA3gFAD07i3P3AegH5W3GMESkK1RAF5ZiMFkxM2vcpSrdO9ADSIUACzhYAJogBGGhdmGIAvqxsAnKlBCOkaqbOnzFAblZ7IA
π» Code
π Actual behavior
value is T inside the if block of value === null (explicit comparison)
π Expected behavior
value should be of type null inside the if block of value === null
Additional information about the issue
No response
The text was updated successfully, but these errors were encountered: