From 5418322b94f94abd383e6cf276775aeb5fd72e71 Mon Sep 17 00:00:00 2001 From: Dhruvil Darji Date: Mon, 23 Feb 2026 22:25:15 -0800 Subject: [PATCH 1/2] Suggest narrowing for None in union attribute errors When accessing an attribute on a union type that includes None, mypy now suggests using an "if x is not None" guard. This helps users, especially beginners, understand how to resolve the common pattern of accessing attributes on Optional types. Fixes #17036 --- mypy/messages.py | 21 +++++++++++++++++++++ test-data/unit/check-inference.test | 6 ++++-- test-data/unit/check-optional.test | 6 ++++-- test-data/unit/check-unions.test | 22 ++++++++++++++++++---- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index ae8673ad93bf4..2ba8876f8c6ac 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -47,6 +47,7 @@ Expression, FuncDef, IndexExpr, + MemberExpr, MypyFile, NameExpr, ReturnStmt, @@ -56,6 +57,7 @@ TypeInfo, Var, get_func_def, + get_member_expr_fullname, reverse_builtin_aliases, ) from mypy.operators import op_methods, op_methods_to_symbols @@ -512,6 +514,25 @@ def has_no_attr( context, code=codes.UNION_ATTR, ) + if typ_format == '"None"': + var_name: str | None = None + if isinstance(context, MemberExpr) and isinstance( + context.expr, NameExpr + ): + var_name = context.expr.name + elif isinstance(context, MemberExpr) and isinstance( + context.expr, MemberExpr + ): + var_name = get_member_expr_fullname(context.expr) + elif isinstance(context, NameExpr): + var_name = context.name + if var_name is not None: + self.note( + 'You can use "if {} is not None" to guard' + " against a None value".format(var_name), + context, + code=codes.UNION_ATTR, + ) return codes.UNION_ATTR elif isinstance(original_type, TypeVarType): bound = get_proper_type(original_type.upper_bound) diff --git a/test-data/unit/check-inference.test b/test-data/unit/check-inference.test index 22edd12b0c4ca..d53ca497a4c4e 100644 --- a/test-data/unit/check-inference.test +++ b/test-data/unit/check-inference.test @@ -3001,7 +3001,8 @@ class C: a = None # E: Need type annotation for "a" (hint: "a: | None = ...") def f(self, x) -> None: - C.a.y # E: Item "None" of "Any | None" has no attribute "y" + C.a.y # E: Item "None" of "Any | None" has no attribute "y" \ + # N: You can use "if C.a is not None" to guard against a None value [case testLocalPartialTypesAccessPartialNoneAttribute2] # flags: --local-partial-types @@ -3009,7 +3010,8 @@ class C: a = None # E: Need type annotation for "a" (hint: "a: | None = ...") def f(self, x) -> None: - self.a.y # E: Item "None" of "Any | None" has no attribute "y" + self.a.y # E: Item "None" of "Any | None" has no attribute "y" \ + # N: You can use "if self.a is not None" to guard against a None value -- Special case for assignment to '_' -- ---------------------------------- diff --git a/test-data/unit/check-optional.test b/test-data/unit/check-optional.test index 6db60275944f3..7b8504dbc1ca8 100644 --- a/test-data/unit/check-optional.test +++ b/test-data/unit/check-optional.test @@ -580,7 +580,8 @@ if int(): if int(): x = f('x') # E: Argument 1 to "f" has incompatible type "str"; expected "int" -x.x = 1 # E: Item "None" of "Node[int] | None" has no attribute "x" +x.x = 1 # E: Item "None" of "Node[int] | None" has no attribute "x" \ + # N: You can use "if x is not None" to guard against a None value if x is not None: x.x = 1 # OK here @@ -633,7 +634,8 @@ A = None # type: Any class C(A): pass x = None # type: Optional[C] -x.foo() # E: Item "None" of "C | None" has no attribute "foo" +x.foo() # E: Item "None" of "C | None" has no attribute "foo" \ + # N: You can use "if x is not None" to guard against a None value [case testIsinstanceAndOptionalAndAnyBase] from typing import Any, Optional diff --git a/test-data/unit/check-unions.test b/test-data/unit/check-unions.test index 1838ec5d145b5..bc92450c496b1 100644 --- a/test-data/unit/check-unions.test +++ b/test-data/unit/check-unions.test @@ -921,12 +921,15 @@ a: Any d: Dict[str, Tuple[List[Tuple[str, str]], str]] x, _ = d.get(a, (None, None)) -for y in x: pass # E: Item "None" of "list[tuple[str, str]] | None" has no attribute "__iter__" (not iterable) +for y in x: pass if x: for s, t in x: - reveal_type(s) # N: Revealed type is "builtins.str" + reveal_type(s) [builtins fixtures/dict.pyi] [out] +main:7: error: Item "None" of "list[tuple[str, str]] | None" has no attribute "__iter__" (not iterable) +main:7: note: You can use "if x is not None" to guard against a None value +main:10: note: Revealed type is "builtins.str" [case testUnpackUnionNoCrashOnPartialNone2] from typing import Dict, Tuple, List, Any @@ -936,12 +939,23 @@ x = None d: Dict[str, Tuple[List[Tuple[str, str]], str]] x, _ = d.get(a, (None, None)) -for y in x: pass # E: Item "None" of "list[tuple[str, str]] | None" has no attribute "__iter__" (not iterable) +for y in x: pass if x: for s, t in x: - reveal_type(s) # N: Revealed type is "builtins.str" + reveal_type(s) [builtins fixtures/dict.pyi] [out] +main:8: error: Item "None" of "list[tuple[str, str]] | None" has no attribute "__iter__" (not iterable) +main:8: note: You can use "if x is not None" to guard against a None value +main:11: note: Revealed type is "builtins.str" + +[case testUnionAttributeNoneNarrowingHint] +from typing import Optional + +def f(s: Optional[str]) -> bool: + return s.startswith('x') # E: Item "None" of "str | None" has no attribute "startswith" \ + # N: You can use "if s is not None" to guard against a None value +[builtins fixtures/ops.pyi] [case testUnpackUnionNoCrashOnPartialNoneBinder] from typing import Dict, Tuple, List, Any From 205fe93865f4df540236db87e1429020185864a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:27:48 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mypy/messages.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 2ba8876f8c6ac..f409a83aa3e1f 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -516,13 +516,9 @@ def has_no_attr( ) if typ_format == '"None"': var_name: str | None = None - if isinstance(context, MemberExpr) and isinstance( - context.expr, NameExpr - ): + if isinstance(context, MemberExpr) and isinstance(context.expr, NameExpr): var_name = context.expr.name - elif isinstance(context, MemberExpr) and isinstance( - context.expr, MemberExpr - ): + elif isinstance(context, MemberExpr) and isinstance(context.expr, MemberExpr): var_name = get_member_expr_fullname(context.expr) elif isinstance(context, NameExpr): var_name = context.name