Repository: python/mypy

Issue:
Flag `None` check on value that cannot be `None`
Say I have function that maybe returns `None` and I check the result:

```python
def f() -> int | None: ...

if f() is None: ...
```

I rewrite the function so that it never returns `None`:

```python
def f() -> int: ...

if f() is None: ...
```

Now the `is None` check makes no sense -- it will always fail. This is a mistake introduced in refactoring and it should be flagged. (And similar for `is not None` checks)

In Rust I don't think this kind of error is possible:

```rust
fn f() -> Option<u8>;

if f().is_none() {}  // okay

fn g() -> u8;

if g().is_none() {} // no good
```


Discussion:
Both `--warn-unreachable` and `--strict-equality` should work for this. But they don't! That's a bug.

Maybe worth closing this to make a bug report? Or someone can change tags (I can't :'( ).
Forgot to add, this affects Mypy 1.14.1 with Python 3.14, using `--strict` setting. (Not sure if `--warn-unreachable` is included in that, but if it isn't it should be.)

Switching the tag from feature to bug is fine with me :)
It isn't included in that. Maybe thumbs up https://github.com/python/mypy/issues/18078!
Here is a full example:

```python
from random import randrange

def f(x: int) -> int | None:
    return None if x == 0 else 2 * x

def g(x: int) -> int:
    return 2 * x

if __name__ == '__main__':
    if f(randrange(10)) is None:
        print('f: got 0')

    if g(randrange(10)) is None:  # <-- unreachable but not flagged
        print('g: got 0')
```

`mypy --strict --warn-unreachable` does not raise any warnings, but it should. So this is a FALSE NEGATIVE bug. Or a new feature, whatever, but in any case this situation should be flagged.
This recently confused me too, but in my case, `--warn-unreachable` fired but `--strict-equality` did not:

```python
g = 1
if g is None:
    y = 1  # error: Statement is unreachable
print(y)

def f() -> int:
    return 1
if f() is None:
    x = 1
print(x)
```

Regarding `--warn-unreachable`, this really seems like a bug to me.  However, regarding `--strict-equality`, it is (or, at least, was) intentional.  See #6370 and here:

https://github.com/python/mypy/blob/116b92bae7b5dbf5e6bd36fd9b0c6804973e5554/mypy/checkexpr.py#L3772-L3780

I opened #19718 as an experiment.  It's not only Mypy that would need many `type: ignore`'s or changes....

Excluding `assert` statements (like Pyright does) would help a little.  Alternatively, we could eventually introduce a `--strict-equality-for-none` flag for a while???






Patch:
diff --git a/docs/source/command_line.rst b/docs/source/command_line.rst
index db2407e17df8..c1b757a00ef2 100644
--- a/docs/source/command_line.rst
+++ b/docs/source/command_line.rst
@@ -728,9 +728,22 @@ of the above sections.
        if text != b'other bytes':  # Error: non-overlapping equality check!
            ...
 
-       assert text is not None  # OK, check against None is allowed as a special case.
+       assert text is not None  # OK, check against None is allowed
 
 
+.. option:: --strict-equality-for-none
+
+    This flag extends :option:`--strict-equality <mypy --strict-equality>` for checks
+    against ``None``:
+
+    .. code-block:: python
+
+       text: str
+       assert text is not None  # Error: non-overlapping identity check!
+
+    Note that :option:`--strict-equality-for-none <mypy --strict-equality-for-none>`
+    only works in combination with :option:`--strict-equality <mypy --strict-equality>`.
+
 .. option:: --strict-bytes
 
     By default, mypy treats ``bytearray`` and ``memoryview`` as subtypes of ``bytes`` which
diff --git a/docs/source/config_file.rst b/docs/source/config_file.rst
index b4f134f26cb1..934e465a7c23 100644
--- a/docs/source/config_file.rst
+++ b/docs/source/config_file.rst
@@ -834,7 +834,15 @@ section of the command line docs.
    :default: False
 
    Prohibit equality checks, identity checks, and container checks between
-   non-overlapping types.
+   non-overlapping types (except ``None``).
+
+.. confval:: strict_equality_for_none
+
+   :type: boolean
+   :default: False
+
+   Include ``None`` in strict equality checks (requires :confval:`strict_equality`
+   to be activated).
 
 .. confval:: strict_bytes
 
diff --git a/docs/source/error_code_list2.rst b/docs/source/error_code_list2.rst
index 784c2ad72819..125671bc2bef 100644
--- a/docs/source/error_code_list2.rst
+++ b/docs/source/error_code_list2.rst
@@ -145,6 +145,29 @@ literal:
     def is_magic(x: bytes) -> bool:
         return x == b'magic'  # OK
 
+:option:`--strict-equality <mypy --strict-equality>` does not include comparisons with
+``None``:
+
+.. code-block:: python
+
+    # mypy: strict-equality
+
+    def is_none(x: str) -> bool:
+        return x is None  # OK
+
+If you want such checks, you must also activate
+:option:`--strict-equality-for-none <mypy --strict-equality-for-none>` (we might merge
+these two options later).
+
+.. code-block:: python
+
+    # mypy: strict-equality strict-equality-for-none
+
+    def is_none(x: str) -> bool:
+        # Error: Non-overlapping identity check
+        #        (left operand type: "str", right operand type: "None")
+        return x is None
+
 .. _code-no-untyped-call:
 
 Check that no untyped functions are called [no-untyped-call]
diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py
index 88b3005b1376..e9620184a1d9 100644
--- a/mypy/checkexpr.py
+++ b/mypy/checkexpr.py
@@ -3721,7 +3721,7 @@ def visit_comparison_expr(self, e: ComparisonExpr) -> Type:
             elif operator == "is" or operator == "is not":
                 right_type = self.accept(right)  # validate the right operand
                 sub_result = self.bool_type()
-                if self.dangerous_comparison(left_type, right_type):
+                if self.dangerous_comparison(left_type, right_type, identity_check=True):
                     # Show the most specific literal types possible
                     left_type = try_getting_literal(left_type)
                     right_type = try_getting_literal(right_type)
@@ -3763,6 +3763,7 @@ def dangerous_comparison(
         original_container: Type | None = None,
         seen_types: set[tuple[Type, Type]] | None = None,
         prefer_literal: bool = True,
+        identity_check: bool = False,
     ) -> bool:
         """Check for dangerous non-overlapping comparisons like 42 == 'no'.
 
@@ -3790,10 +3791,12 @@ def dangerous_comparison(
 
         left, right = get_proper_types((left, right))
 
-        # We suppress the error if there is a custom __eq__() method on either
-        # side. User defined (or even standard library) classes can define this
+        # We suppress the error for equality and container checks if there is a custom __eq__()
+        # method on either side. User defined (or even standard library) classes can define this
         # to return True for comparisons between non-overlapping types.
-        if custom_special_method(left, "__eq__") or custom_special_method(right, "__eq__"):
+        if (
+            custom_special_method(left, "__eq__") or custom_special_method(right, "__eq__")
+        ) and not identity_check:
             return False
 
         if prefer_literal:
@@ -3817,7 +3820,10 @@ def dangerous_comparison(
             #
             # TODO: find a way of disabling the check only for types resulted from the expansion.
             return False
-        if isinstance(left, NoneType) or isinstance(right, NoneType):
+        if self.chk.options.strict_equality_for_none:
+            if isinstance(left, NoneType) and isinstance(right, NoneType):
+                return False
+        elif isinstance(left, NoneType) or isinstance(right, NoneType):
             return False
         if isinstance(left, UnionType) and isinstance(right, UnionType):
             left = remove_optional(left)
diff --git a/mypy/main.py b/mypy/main.py
index 0f70eb41bb14..9f6431376e40 100644
--- a/mypy/main.py
+++ b/mypy/main.py
@@ -905,7 +905,16 @@ def add_invertible_flag(
         "--strict-equality",
         default=False,
         strict_flag=True,
-        help="Prohibit equality, identity, and container checks for non-overlapping types",
+        help="Prohibit equality, identity, and container checks for non-overlapping types "
+        "(except `None`)",
+        group=strictness_group,
+    )
+
+    add_invertible_flag(
+        "--strict-equality-for-none",
+        default=False,
+        strict_flag=False,
+        help="Extend `--strict-equality` for `None` checks",
         group=strictness_group,
     )
 
diff --git a/mypy/options.py b/mypy/options.py
index ad4b26cca095..b3dc9639a41d 100644
--- a/mypy/options.py
+++ b/mypy/options.py
@@ -55,6 +55,7 @@ class BuildType:
     "mypyc",
     "strict_concatenate",
     "strict_equality",
+    "strict_equality_for_none",
     "strict_optional",
     "warn_no_return",
     "warn_return_any",
@@ -230,6 +231,9 @@ def __init__(self) -> None:
         # This makes 1 == '1', 1 in ['1'], and 1 is '1' errors.
         self.strict_equality = False
 
+        # Extend the logic of `scrict_equality` for comparisons with `None`.
+        self.strict_equality_for_none = False
+
         # Disable treating bytearray and memoryview as subtypes of bytes
         self.strict_bytes = False
 


Test patch:
diff --git a/test-data/unit/check-expressions.test b/test-data/unit/check-expressions.test
index 33271a3cc04c..ea6eac9a39b3 100644
--- a/test-data/unit/check-expressions.test
+++ b/test-data/unit/check-expressions.test
@@ -2419,6 +2419,35 @@ assert a == c
 [builtins fixtures/list.pyi]
 [typing fixtures/typing-full.pyi]
 
+[case testStrictEqualityForNone]
+# flags: --strict-equality --strict-equality-for-none
+
+class A: ...
+
+def a1(x: A) -> None:
+    assert x is None  # E: Non-overlapping identity check (left operand type: "A", right operand type: "None")
+def a2(x: A) -> None:
+    x is not None  # E: Non-overlapping identity check (left operand type: "A", right operand type: "None")
+def a3(x: A) -> None:
+    None == x  # E: Non-overlapping equality check (left operand type: "None", right operand type: "A")
+def a4(x: list[A]) -> None:
+    None in x  # E: Non-overlapping container check (element type: "None", container item type: "A")
+
+class B:
+    def __eq__(self, x: object) -> bool: ...
+
+def b1(x: B) -> None:
+    assert x is None  # E: Non-overlapping identity check (left operand type: "B", right operand type: "None")
+def b2(x: B) -> None:
+    x is not None  # E: Non-overlapping identity check (left operand type: "B", right operand type: "None")
+def b3(x: B) -> None:
+    x == None
+def b4(x: list[B]) -> None:
+    None in x
+
+[builtins fixtures/list.pyi]
+[typing fixtures/typing-full.pyi]
+
 [case testUnimportedHintAny]
 def f(x: Any) -> None:  # E: Name "Any" is not defined \
                         # N: Did you forget to import it from "typing"? (Suggestion: "from typing import Any")