Repository: python/mypy

Issue:
explicit `x is None` should narrow union candidates like `isinstance(x, type(None))`
```
from typing import Optional, List  # noqa


def union_test1(x):
    # type: (Optional[List[int]]) -> Optional[int]
    if x is None:
        return x
    else:
        return len(x)


def union_test2(x):
    # type: (Optional[List[int]]) -> Optional[int]
    if isinstance(x, type(None)):
        return x
    else:
        return len(x)
```

`union_test1` with default options gives the error `Incompatible return value type (got "Optional[List[int]]", expected "Optional[int]")` (with `--strict-optional`, it does not give this error). `union_test2` typechecks just fine. This strikes me as highly counterintuitive; even though (as the docs say) `None` is a valid value for each type, if one of the above checks allows the typechecker to learn that `x` is `None` and *therefore* not a list, it's hard to see why the other doesn't.

Failing that, I would suggest at least a documentation change to point out both the gotcha embodied in `union_test1` and the workaround in `union_test2`.


Discussion:
Agreed, I don't see why the first one isn't treated the same as the second.
> Agreed, I don't see why the first one isn't treated the same as the second.

This is not the first time I see weird behaviour of `None` _without_ `--strict-optional`.
It looks like the code in `find_isinstance_check` to refine optionals based on comparisons is explicitly gated on `--strict-optional`: https://github.com/python/mypy/blob/master/mypy/checker.py#L2865

Is there any reason to not just change that?

Resolving patch:
diff --git a/mypy/binder.py b/mypy/binder.py
--- a/mypy/binder.py
+++ b/mypy/binder.py
@@ -267,15 +267,13 @@ def assign_type(self, expr: Expression,
             return
 
         enclosing_type = self.most_recent_enclosing_type(expr, type)
-        if (isinstance(enclosing_type, AnyType)
-                and not restrict_any):
+        if isinstance(enclosing_type, AnyType) and not restrict_any:
             # If x is Any and y is int, after x = y we do not infer that x is int.
             # This could be changed.
-            if not isinstance(type, AnyType):
-                # We narrowed type from Any in a recent frame (probably an
-                # isinstance check), but now it is reassigned, so broaden back
-                # to Any (which is the most recent enclosing type)
-                self.put(expr, enclosing_type)
+            # Instead, since we narrowed type from Any in a recent frame (probably an
+            # isinstance check), but now it is reassigned, we broaden back
+            # to Any (which is the most recent enclosing type)
+            self.put(expr, enclosing_type)
         # As a special case, when assigning Any to a variable with a
         # declared Optional type that has been narrowed to None,
         # replace all the Nones in the declared Union type with Any.
diff --git a/mypy/checker.py b/mypy/checker.py
--- a/mypy/checker.py
+++ b/mypy/checker.py
@@ -3330,7 +3330,7 @@ def find_isinstance_check(self, node: Expression
                 if literal(expr) == LITERAL_TYPE:
                     vartype = type_map[expr]
                     return self.conditional_callable_type_map(expr, vartype)
-        elif isinstance(node, ComparisonExpr) and experiments.STRICT_OPTIONAL:
+        elif isinstance(node, ComparisonExpr):
             # Check for `x is None` and `x is not None`.
             is_not = node.operators == ['is not']
             if any(is_literal_none(n) for n in node.operands) and (


Test patch:
diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test
--- a/test-data/unit/check-isinstance.test
+++ b/test-data/unit/check-isinstance.test
@@ -2077,9 +2077,7 @@ class A: pass
 
 def foo1(x: Union[A, str, None]) -> None:
     if x is None:
-        # Since None is a subtype of all types in no-strict-optional,
-        # we can't really narrow the type here
-        reveal_type(x)      # E: Revealed type is 'Union[__main__.A, builtins.str, None]'
+        reveal_type(x)      # E: Revealed type is 'None'
     elif isinstance(x, A):
         # Note that Union[None, A] == A in no-strict-optional
         reveal_type(x)      # E: Revealed type is '__main__.A'
@@ -2088,12 +2086,12 @@ def foo1(x: Union[A, str, None]) -> None:
 
 def foo2(x: Optional[str]) -> None:
     if x is None:
-        reveal_type(x)      # E: Revealed type is 'Union[builtins.str, None]'
+        reveal_type(x)      # E: Revealed type is 'None'
     elif isinstance(x, A):
         # Mypy should, however, be able to skip impossible cases
         reveal_type(x)
     else:
-        reveal_type(x)      # E: Revealed type is 'Union[builtins.str, None]'
+        reveal_type(x)      # E: Revealed type is 'builtins.str'
 [builtins fixtures/isinstance.pyi]
 
 [case testNoneCheckDoesNotNarrowWhenUsingTypeVars]
@@ -2153,7 +2151,7 @@ def bar(x: Union[List[str], List[int], None]) -> None:
 from typing import Union, Optional, List
 
 # This test is the same as the one above, except for strict-optional.
-# It isn't testing anything explicitly and mostly exists for the sake 
+# It isn't testing anything explicitly and mostly exists for the sake
 # of completeness.
 
 def foo(x: Optional[List[str]]) -> None:
@@ -2166,4 +2164,3 @@ def bar(x: Union[List[str], List[int], None]) -> None:
     assert isinstance(x, list)
     reveal_type(x)                  # E: Revealed type is 'Union[builtins.list[builtins.str], builtins.list[builtins.int]]'
 [builtins fixtures/isinstancelist.pyi]
-
diff --git a/test-data/unit/check-tuples.test b/test-data/unit/check-tuples.test
--- a/test-data/unit/check-tuples.test
+++ b/test-data/unit/check-tuples.test
@@ -1099,7 +1099,7 @@ possibles: Tuple[int, Tuple[A]]
 x: Optional[Tuple[B]]
 
 if x in possibles:
-    reveal_type(x) # E: Revealed type is 'Union[Tuple[__main__.B], None]'
+    reveal_type(x) # E: Revealed type is 'Tuple[__main__.B]'
 else:
     reveal_type(x) # E: Revealed type is 'Union[Tuple[__main__.B], None]'
 
diff --git a/test-data/unit/check-unions.test b/test-data/unit/check-unions.test
--- a/test-data/unit/check-unions.test
+++ b/test-data/unit/check-unions.test
@@ -948,3 +948,31 @@ MYTYPE = List[Union[str, "MYTYPE"]]
 [builtins fixtures/list.pyi]
 [out]
 main:2: error: Recursive types not fully supported yet, nested types replaced with "Any"
+
+[case testNonStrictOptional]
+from typing import Optional, List
+
+def union_test1(x):
+    # type: (Optional[List[int]]) -> Optional[int]
+    if x is None:
+        return x
+    else:
+        return x[0]
+
+def union_test2(x):
+    # type: (Optional[List[int]]) -> Optional[int]
+    if isinstance(x, type(None)):
+        return x
+    else:
+        return x[0]
+
+def f(): return 0
+
+def union_test3():
+    # type: () -> int
+    x = f()
+    assert x is None
+    x = f()
+    return x + 1
+
+[builtins fixtures/isinstancelist.pyi]
diff --git a/test-data/unit/check-unreachable-code.test b/test-data/unit/check-unreachable-code.test
--- a/test-data/unit/check-unreachable-code.test
+++ b/test-data/unit/check-unreachable-code.test
@@ -618,7 +618,7 @@ class Child(Parent):
     def foo(self) -> int:
         reveal_type(self)       # E: Revealed type is '__main__.Child'
         if self is None:
-            reveal_type(self)   # E: Revealed type is '__main__.Child'
+            reveal_type(self)   # E: Revealed type is 'None'
             return None
         reveal_type(self)       # E: Revealed type is '__main__.Child'
         return 3