From 19ba56bbeeec05aa9054e63622bdba8f55cec69e Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 20 Nov 2025 18:39:06 +0000 Subject: [PATCH 01/12] Neaten double check to type guard --- mypy/checker.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 07f5c520de95..dab8ef69e9b4 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6231,9 +6231,13 @@ def find_type_equals_check( compared """ - def is_type_call(expr: CallExpr) -> bool: + def is_type_call(expr: Expression) -> TypeGuard[CallExpr]: """Is expr a call to type with one argument?""" - return refers_to_fullname(expr.callee, "builtins.type") and len(expr.args) == 1 + return ( + isinstance(expr, CallExpr) + and refers_to_fullname(expr.callee, "builtins.type") + and len(expr.args) == 1 + ) # exprs that are being passed into type exprs_in_type_calls: list[Expression] = [] @@ -6245,7 +6249,7 @@ def is_type_call(expr: CallExpr) -> bool: for index in expr_indices: expr = node.operands[index] - if isinstance(expr, CallExpr) and is_type_call(expr): + if is_type_call(expr): exprs_in_type_calls.append(expr.args[0]) else: current_type = self.get_isinstance_type(expr) From df86e646e1b5fb69b4891fa281ea4056fcf5153f Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 20 Nov 2025 18:42:55 +0000 Subject: [PATCH 02/12] Use least type during type equality comparison --- mypy/checker.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/mypy/checker.py b/mypy/checker.py index dab8ef69e9b4..9dce9b3a6a1a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Iterable, Iterator, Mapping, Sequence, Set as AbstractSet from contextlib import ExitStack, contextmanager +from functools import reduce from typing import ( Callable, Final, @@ -6271,6 +6272,13 @@ def is_type_call(expr: Expression) -> TypeGuard[CallExpr]: if not exprs_in_type_calls: return {}, {} + if type_being_compared is None: + # TODO: use more accurate lower bound analysis + least_type = self.least_type([self.lookup_type(expr) for expr in exprs_in_type_calls]) + type_being_compared = ( + None if least_type is None else [TypeRange(least_type, is_upper_bound=True)] + ) + if_maps: list[TypeMap] = [] else_maps: list[TypeMap] = [] for expr in exprs_in_type_calls: @@ -6304,6 +6312,28 @@ def combine_maps(list_maps: list[TypeMap]) -> TypeMap: else_map = {} return if_map, else_map + def least_type(self, types: list[Type]) -> Type | None: + """Find the type of which all other types are supertypes. + `Any` types are i + For example, `least_type([dict, defaultdict, object]) => dict`. + However, `least_type[int, str]) => None`. + + It would be better if we could represent an intersection of types. + + For example, consider `s: str` and `i: int`. + `type(s) == type(i)` implies `s: str & int` and `i: str & int`, + even though `s: object` and `i: object` also hold. + """ + types = [typ for typ in types if not isinstance(typ, AnyType)] + if not types: + return None + least_type = reduce(lambda t1, t2: t1 if is_subtype(t1, t2) else t2, types) + + if all(typ is least_type or is_subtype(least_type, typ) for typ in types): + # Ensure that this is a least type + return least_type + return None + def find_isinstance_check( self, node: Expression, *, in_boolean_context: bool = True ) -> tuple[TypeMap, TypeMap]: From 9b8aadb9b688f6301235c1813f3f9e7840a84d45 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 20 Nov 2025 18:48:01 +0000 Subject: [PATCH 03/12] Keep equal types in chained comparison --- mypy/checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/checker.py b/mypy/checker.py index 9dce9b3a6a1a..14e1c4f910d3 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6256,7 +6256,7 @@ def is_type_call(expr: Expression) -> TypeGuard[CallExpr]: current_type = self.get_isinstance_type(expr) if current_type is None: continue - if type_being_compared is not None: + if type_being_compared is not None and type_being_compared != current_type: # It doesn't really make sense to have several types being # compared to the output of type (like type(x) == int == str) # because whether that's true is solely dependent on what the From 50939a5571a978b4000ffc1cf8ae72547b31279d Mon Sep 17 00:00:00 2001 From: George Ogden Date: Thu, 20 Nov 2025 20:19:51 +0000 Subject: [PATCH 04/12] Add unit tests for new cases --- test-data/unit/check-isinstance.test | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test index acd4b588f98c..97558a308417 100644 --- a/test-data/unit/check-isinstance.test +++ b/test-data/unit/check-isinstance.test @@ -2716,6 +2716,10 @@ if type(x) == type(y) == int: reveal_type(y) # N: Revealed type is "builtins.int" reveal_type(x) # N: Revealed type is "builtins.int" +z: Any +if int == type(z) == int: + reveal_type(z) # N: Revealed type is "builtins.int" + [case testTypeEqualsCheckUsingIs] from typing import Any @@ -2723,6 +2727,32 @@ y: Any if type(y) is int: reveal_type(y) # N: Revealed type is "builtins.int" +[case testTypeEqualsCheckUsingImplicitTypes] +from typing import Any + +x: str +y: Any +z: object +if type(y) is type(x): + reveal_type(x) # N: Revealed type is "builtins.str" + reveal_type(y) # N: Revealed type is "builtins.str" + +if type(x) is type(z): + reveal_type(x) # N: Revealed type is "builtins.str" + reveal_type(z) # N: Revealed type is "builtins.str" + +[case testTypeEqualsCheckUsingDifferentSpecializedTypes] +from collections import defaultdict + +x: defaultdict +y: dict +z: object +if type(x) is type(y) is type(z): + reveal_type(x) # N: Revealed type is "collections.defaultdict[Any, Any]" + reveal_type(y) # N: Revealed type is "collections.defaultdict[Any, Any]" + reveal_type(z) # N: Revealed type is "collections.defaultdict[Any, Any]" + + [case testTypeEqualsCheckUsingIsNonOverlapping] # flags: --warn-unreachable from typing import Union From 1189e885c28ec28a816d9f22ffe949378dbdd8fa Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 21 Nov 2025 08:32:50 +0000 Subject: [PATCH 05/12] Revert "Neaten double check to type guard" This reverts commit 19ba56bbeeec05aa9054e63622bdba8f55cec69e. --- mypy/checker.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 14e1c4f910d3..ea516ed58cb0 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6232,13 +6232,9 @@ def find_type_equals_check( compared """ - def is_type_call(expr: Expression) -> TypeGuard[CallExpr]: + def is_type_call(expr: CallExpr) -> bool: """Is expr a call to type with one argument?""" - return ( - isinstance(expr, CallExpr) - and refers_to_fullname(expr.callee, "builtins.type") - and len(expr.args) == 1 - ) + return refers_to_fullname(expr.callee, "builtins.type") and len(expr.args) == 1 # exprs that are being passed into type exprs_in_type_calls: list[Expression] = [] @@ -6250,7 +6246,7 @@ def is_type_call(expr: Expression) -> TypeGuard[CallExpr]: for index in expr_indices: expr = node.operands[index] - if is_type_call(expr): + if isinstance(expr, CallExpr) and is_type_call(expr): exprs_in_type_calls.append(expr.args[0]) else: current_type = self.get_isinstance_type(expr) From 9e50b14ff25f55401ef201de604a48d2493c18df Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 21 Nov 2025 08:58:53 +0000 Subject: [PATCH 06/12] Compare type ranges using is_same_type_ranges --- mypy/checker.py | 35 ++++++++--------------------------- mypy/subtypes.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index ea516ed58cb0..99ac7d265c12 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -167,6 +167,7 @@ is_more_precise, is_proper_subtype, is_same_type, + is_same_type_ranges, is_subtype, restrict_subtype_away, unify_generic_callable, @@ -6252,9 +6253,12 @@ def is_type_call(expr: CallExpr) -> bool: current_type = self.get_isinstance_type(expr) if current_type is None: continue - if type_being_compared is not None and type_being_compared != current_type: + if type_being_compared is not None and not is_same_type_ranges( + type_being_compared, current_type + ): # It doesn't really make sense to have several types being # compared to the output of type (like type(x) == int == str) + # unless they are the same (like type(x) == float == float) # because whether that's true is solely dependent on what the # types being compared are, so we don't try to narrow types any # further because we can't really get any information about the @@ -6269,11 +6273,10 @@ def is_type_call(expr: CallExpr) -> bool: return {}, {} if type_being_compared is None: - # TODO: use more accurate lower bound analysis - least_type = self.least_type([self.lookup_type(expr) for expr in exprs_in_type_calls]) - type_being_compared = ( - None if least_type is None else [TypeRange(least_type, is_upper_bound=True)] + least_type = reduce( + meet_types, (self.lookup_type(expr) for expr in exprs_in_type_calls) ) + type_being_compared = [TypeRange(least_type, is_upper_bound=True)] if_maps: list[TypeMap] = [] else_maps: list[TypeMap] = [] @@ -6308,28 +6311,6 @@ def combine_maps(list_maps: list[TypeMap]) -> TypeMap: else_map = {} return if_map, else_map - def least_type(self, types: list[Type]) -> Type | None: - """Find the type of which all other types are supertypes. - `Any` types are i - For example, `least_type([dict, defaultdict, object]) => dict`. - However, `least_type[int, str]) => None`. - - It would be better if we could represent an intersection of types. - - For example, consider `s: str` and `i: int`. - `type(s) == type(i)` implies `s: str & int` and `i: str & int`, - even though `s: object` and `i: object` also hold. - """ - types = [typ for typ in types if not isinstance(typ, AnyType)] - if not types: - return None - least_type = reduce(lambda t1, t2: t1 if is_subtype(t1, t2) else t2, types) - - if all(typ is least_type or is_subtype(least_type, typ) for typ in types): - # Ensure that this is a least type - return least_type - return None - def find_isinstance_check( self, node: Expression, *, in_boolean_context: bool = True ) -> tuple[TypeMap, TypeMap]: diff --git a/mypy/subtypes.py b/mypy/subtypes.py index c02ff068560b..a4556c56ba00 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -8,6 +8,7 @@ import mypy.applytype import mypy.constraints import mypy.typeops +from mypy.checker_shared import TypeRange from mypy.checker_state import checker_state from mypy.erasetype import erase_type from mypy.expandtype import ( @@ -255,6 +256,29 @@ def is_equivalent( ) +def is_same_type_ranges( + a: list[TypeRange], + b: list[TypeRange], + ignore_promotions: bool = True, + subtype_context: SubtypeContext | None = None, +) -> bool: + return len(a) == len(b) and all( + is_same_type_range(a, b, ignore_promotions, subtype_context) + for a, b in zip(a, b, strict=True) + ) + + +def is_same_type_range( + a: list[TypeRange], + b: list[TypeRange], + ignore_promotions: bool = True, + subtype_context: SubtypeContext | None = None, +) -> bool: + return a.is_upper_bound == b.is_upper_bound and is_same_type( + a.item, b.item, ignore_promotions, subtype_context + ) + + def is_same_type( a: Type, b: Type, ignore_promotions: bool = True, subtype_context: SubtypeContext | None = None ) -> bool: From cf82cb50a17894c9a50c1505c9a237934eaaf06b Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 21 Nov 2025 09:02:44 +0000 Subject: [PATCH 07/12] Remove strict keyword from zip --- mypy/subtypes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index a4556c56ba00..2a505bbab2d4 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -263,8 +263,7 @@ def is_same_type_ranges( subtype_context: SubtypeContext | None = None, ) -> bool: return len(a) == len(b) and all( - is_same_type_range(a, b, ignore_promotions, subtype_context) - for a, b in zip(a, b, strict=True) + is_same_type_range(x, y, ignore_promotions, subtype_context) for x, y in zip(a, b) ) From 2a5870cbe0ce566a8d66fb81c59a770f75f60f37 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 21 Nov 2025 09:04:13 +0000 Subject: [PATCH 08/12] Fix type hint on is_same_type_range --- mypy/subtypes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 2a505bbab2d4..0cb9cbe29fc7 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -268,8 +268,8 @@ def is_same_type_ranges( def is_same_type_range( - a: list[TypeRange], - b: list[TypeRange], + a: TypeRange, + b: TypeRange, ignore_promotions: bool = True, subtype_context: SubtypeContext | None = None, ) -> bool: From d993793a7a5a86c1aef81113878f63bceee60201 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Fri, 21 Nov 2025 22:07:32 +0000 Subject: [PATCH 09/12] Rewrite find type equality to intersect instances --- mypy/checker.py | 178 +++++++++++++++++---------- test-data/unit/check-isinstance.test | 34 ++++- 2 files changed, 147 insertions(+), 65 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 99ac7d265c12..2c4b01abe6d1 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6,7 +6,6 @@ from collections import defaultdict from collections.abc import Iterable, Iterator, Mapping, Sequence, Set as AbstractSet from contextlib import ExitStack, contextmanager -from functools import reduce from typing import ( Callable, Final, @@ -26,6 +25,7 @@ from mypy.binder import ConditionalTypeBinder, Frame, get_declaration from mypy.checker_shared import CheckerScope, TypeCheckerSharedApi, TypeRange from mypy.checker_state import checker_state +from mypy.checkexpr import type_info_from_type from mypy.checkmember import ( MemberContext, analyze_class_attribute_access, @@ -167,7 +167,6 @@ is_more_precise, is_proper_subtype, is_same_type, - is_same_type_ranges, is_subtype, restrict_subtype_away, unify_generic_callable, @@ -6239,56 +6238,104 @@ def is_type_call(expr: CallExpr) -> bool: # exprs that are being passed into type exprs_in_type_calls: list[Expression] = [] - # type that is being compared to type(expr) - type_being_compared: list[TypeRange] | None = None - # whether the type being compared to is final + # all the types that an expression will have if the overall expression is truthy + target_types: list[Instance] = [] + # only a single type can be used when passed directly (eg "str") + fixed_type: TypeRange | None = None + # is this single type final? is_final = False + def update_fixed_type(new_fixed_type: TypeRange, new_is_final: bool) -> bool: + """Returns if the update succeeds""" + nonlocal fixed_type, is_final + if update := ( + fixed_type is None + or ( + new_fixed_type.is_upper_bound == fixed_type.is_upper_bound + and is_same_type(new_fixed_type.item, fixed_type.item) + ) + ): + fixed_type = new_fixed_type + is_final = new_is_final + return update + for index in expr_indices: expr = node.operands[index] if isinstance(expr, CallExpr) and is_type_call(expr): - exprs_in_type_calls.append(expr.args[0]) + arg = expr.args[0] + exprs_in_type_calls.append(arg) + typ = self.lookup_type(arg) + if typ is None: + continue + type_range = self.isinstance_type_range(get_proper_type(typ)) else: - current_type = self.get_isinstance_type(expr) - if current_type is None: + proper_type = get_proper_type(self.lookup_type(expr)) + # cannot read type + if proper_type is None: continue - if type_being_compared is not None and not is_same_type_ranges( - type_being_compared, current_type - ): - # It doesn't really make sense to have several types being - # compared to the output of type (like type(x) == int == str) - # unless they are the same (like type(x) == float == float) - # because whether that's true is solely dependent on what the - # types being compared are, so we don't try to narrow types any - # further because we can't really get any information about the - # type of x from that check - return {}, {} - else: - if isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo): - is_final = expr.node.is_final - type_being_compared = current_type + # get the range as though we were using isinstance + type_range = self.isinstance_type_range(proper_type) + # None range means this should not be used in comparison (eg tuple) + if type_range is None: + fixed_type = TypeRange(UninhabitedType(), True) + continue + + if isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo): + if not update_fixed_type(type_range, expr.node.is_final): + return None, {} + typ = type_range.item + + type_info = type_info_from_type(typ) + if type_info is not None: + target_types.append( + Instance( + type_info, + [AnyType(TypeOfAny.special_form)] * len(type_info.defn.type_vars), + ) + ) if not exprs_in_type_calls: return {}, {} - if type_being_compared is None: - least_type = reduce( - meet_types, (self.lookup_type(expr) for expr in exprs_in_type_calls) - ) - type_being_compared = [TypeRange(least_type, is_upper_bound=True)] + if target_types: + least_type: Type | None = target_types[0] + for target_type in target_types[1:]: + if fixed_type is None: + # intersect types if fixed type doesn't need keeping + least_type = self.intersect_instances( + (cast(Instance, least_type), target_type), [] # what to do with errors? + ) + else: + # otherwise, be safe and use meet + least_type = meet_types(cast(Type, least_type), target_type) + if least_type is None: + break + elif fixed_type: + least_type = fixed_type.item + else: + # no bounds means no inference can be made + return {}, {} + + # if the type differs from the fixed type, comparison cannot succeed + if least_type is None or ( + fixed_type is not None and not is_same_type(least_type, fixed_type.item) + ): + return None, {} + + shared_type = [TypeRange(least_type, not is_final)] if_maps: list[TypeMap] = [] else_maps: list[TypeMap] = [] for expr in exprs_in_type_calls: - current_if_type, current_else_type = self.conditional_types_with_intersection( - self.lookup_type(expr), type_being_compared, expr - ) - current_if_map, current_else_map = conditional_types_to_typemaps( - expr, current_if_type, current_else_type + if_map, else_map = conditional_types_to_typemaps( + expr, + *self.conditional_types_with_intersection( + self.lookup_type(expr), shared_type, expr + ), ) - if_maps.append(current_if_map) - else_maps.append(current_else_map) + if_maps.append(if_map) + else_maps.append(else_map) def combine_maps(list_maps: list[TypeMap]) -> TypeMap: """Combine all typemaps in list_maps into one typemap""" @@ -7050,7 +7097,6 @@ def refine_away_none_in_comparison( if_map, else_map = {}, {} if not non_optional_types or (len(non_optional_types) != len(chain_indices)): - # Narrow e.g. `Optional[A] == "x"` or `Optional[A] is "x"` to `A` (which may be # convenient but is strictly not type-safe): for i in narrowable_operand_indices: @@ -7972,35 +8018,41 @@ def get_isinstance_type(self, expr: Expression) -> list[TypeRange] | None: return None return left + right all_types = get_proper_types(flatten_types(self.lookup_type(expr))) - types: list[TypeRange] = [] + type_ranges: list[TypeRange] = [] for typ in all_types: - if isinstance(typ, FunctionLike) and typ.is_type_obj(): - # If a type is generic, `isinstance` can only narrow its variables to Any. - any_parameterized = fill_typevars_with_any(typ.type_object()) - # Tuples may have unattended type variables among their items - if isinstance(any_parameterized, TupleType): - erased_type = erase_typevars(any_parameterized) - else: - erased_type = any_parameterized - types.append(TypeRange(erased_type, is_upper_bound=False)) - elif isinstance(typ, TypeType): - # Type[A] means "any type that is a subtype of A" rather than "precisely type A" - # we indicate this by setting is_upper_bound flag - is_upper_bound = True - if isinstance(typ.item, NoneType): - # except for Type[None], because "'NoneType' is not an acceptable base type" - is_upper_bound = False - types.append(TypeRange(typ.item, is_upper_bound=is_upper_bound)) - elif isinstance(typ, Instance) and typ.type.fullname == "builtins.type": - object_type = Instance(typ.type.mro[-1], []) - types.append(TypeRange(object_type, is_upper_bound=True)) - elif isinstance(typ, Instance) and typ.type.fullname == "types.UnionType" and typ.args: - types.append(TypeRange(UnionType(typ.args), is_upper_bound=False)) - elif isinstance(typ, AnyType): - types.append(TypeRange(typ, is_upper_bound=False)) - else: # we didn't see an actual type, but rather a variable with unknown value + type_range = self.isinstance_type_range(typ) + if type_range is None: return None - return types + type_ranges.append(type_range) + return type_ranges + + def isinstance_type_range(self, typ: ProperType) -> TypeRange | None: + if isinstance(typ, FunctionLike) and typ.is_type_obj(): + # If a type is generic, `isinstance` can only narrow its variables to Any. + any_parameterized = fill_typevars_with_any(typ.type_object()) + # Tuples may have unattended type variables among their items + if isinstance(any_parameterized, TupleType): + erased_type = erase_typevars(any_parameterized) + else: + erased_type = any_parameterized + return TypeRange(erased_type, is_upper_bound=False) + elif isinstance(typ, TypeType): + # Type[A] means "any type that is a subtype of A" rather than "precisely type A" + # we indicate this by setting is_upper_bound flag + is_upper_bound = True + if isinstance(typ.item, NoneType): + # except for Type[None], because "'NoneType' is not an acceptable base type" + is_upper_bound = False + return TypeRange(typ.item, is_upper_bound=is_upper_bound) + elif isinstance(typ, Instance) and typ.type.fullname == "builtins.type": + object_type = Instance(typ.type.mro[-1], []) + return TypeRange(object_type, is_upper_bound=True) + elif isinstance(typ, Instance) and typ.type.fullname == "types.UnionType" and typ.args: + return TypeRange(UnionType(typ.args), is_upper_bound=False) + elif isinstance(typ, AnyType): + return TypeRange(typ, is_upper_bound=False) + else: # we didn't see an actual type, but rather a variable with unknown value + return None def is_literal_enum(self, n: Expression) -> bool: """Returns true if this expression (with the given type context) is an Enum literal. diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test index 97558a308417..504d635d6c66 100644 --- a/test-data/unit/check-isinstance.test +++ b/test-data/unit/check-isinstance.test @@ -2752,13 +2752,42 @@ if type(x) is type(y) is type(z): reveal_type(y) # N: Revealed type is "collections.defaultdict[Any, Any]" reveal_type(z) # N: Revealed type is "collections.defaultdict[Any, Any]" +[case testUnionTypeEquality] +from typing import Any, reveal_type +# flags: --warn-unreachable + +x: Any = () +if type(x) == (int, str): + reveal_type(x) # E: Statement is unreachable + +[builtins fixtures/tuple.pyi] + +[case testTypeIntersectionWithConcreteTypes] +class X: x = 1 +class Y: y = 1 +class Z(X, Y): ... + +z = Z() +x: X = z +y: Y = z +if type(x) is type(y): + reveal_type(x) # N: Revealed type is "__main__." + reveal_type(y) # N: Revealed type is "__main__." + x.y + y.x + +if isinstance(x, type(y)) and isinstance(y, type(x)): + reveal_type(x) # N: Revealed type is "__main__." + reveal_type(y) # N: Revealed type is "__main__." + x.y + y.x + +[builtins fixtures/isinstance.pyi] [case testTypeEqualsCheckUsingIsNonOverlapping] # flags: --warn-unreachable from typing import Union y: str -if type(y) is int: # E: Subclass of "str" and "int" cannot exist: would have incompatible method signatures +if type(y) is int: y # E: Statement is unreachable else: reveal_type(y) # N: Revealed type is "builtins.str" @@ -2791,12 +2820,13 @@ else: [case testTypeEqualsMultipleTypesShouldntNarrow] # make sure we don't do any narrowing if there are multiple types being compared +# flags: --warn-unreachable from typing import Union x: Union[int, str] if type(x) == int == str: - reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]" + reveal_type(x) # E: Statement is unreachable else: reveal_type(x) # N: Revealed type is "Union[builtins.int, builtins.str]" From 91fbc3e62563620e248a4368ffb577da0b5bc8d0 Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sun, 23 Nov 2025 10:16:58 +0000 Subject: [PATCH 10/12] Remove redundant code --- mypy/checker.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 2c4b01abe6d1..606ed45b7f3e 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -6266,14 +6266,8 @@ def update_fixed_type(new_fixed_type: TypeRange, new_is_final: bool) -> bool: arg = expr.args[0] exprs_in_type_calls.append(arg) typ = self.lookup_type(arg) - if typ is None: - continue - type_range = self.isinstance_type_range(get_proper_type(typ)) else: proper_type = get_proper_type(self.lookup_type(expr)) - # cannot read type - if proper_type is None: - continue # get the range as though we were using isinstance type_range = self.isinstance_type_range(proper_type) # None range means this should not be used in comparison (eg tuple) From 7a5f33ed1f443ebbbe6b52c1eafb46e87d8d9c4f Mon Sep 17 00:00:00 2001 From: George Ogden Date: Sun, 23 Nov 2025 15:02:59 +0000 Subject: [PATCH 11/12] Rewrite type equals narrowing with type ranges --- mypy/checker.py | 139 +++++++++++---------------- test-data/unit/check-isinstance.test | 5 +- 2 files changed, 57 insertions(+), 87 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 606ed45b7f3e..1d25f089fc67 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import itertools from collections import defaultdict from collections.abc import Iterable, Iterator, Mapping, Sequence, Set as AbstractSet @@ -25,7 +26,6 @@ from mypy.binder import ConditionalTypeBinder, Frame, get_declaration from mypy.checker_shared import CheckerScope, TypeCheckerSharedApi, TypeRange from mypy.checker_state import checker_state -from mypy.checkexpr import type_info_from_type from mypy.checkmember import ( MemberContext, analyze_class_attribute_access, @@ -6239,115 +6239,86 @@ def is_type_call(expr: CallExpr) -> bool: # exprs that are being passed into type exprs_in_type_calls: list[Expression] = [] # all the types that an expression will have if the overall expression is truthy - target_types: list[Instance] = [] + target_types: list[list[TypeRange]] = [] # only a single type can be used when passed directly (eg "str") - fixed_type: TypeRange | None = None + fixed_type: Type | None = None # is this single type final? is_final = False - def update_fixed_type(new_fixed_type: TypeRange, new_is_final: bool) -> bool: + def update_fixed_type(new_fixed_type: Type, new_is_final: bool) -> bool: """Returns if the update succeeds""" nonlocal fixed_type, is_final - if update := ( - fixed_type is None - or ( - new_fixed_type.is_upper_bound == fixed_type.is_upper_bound - and is_same_type(new_fixed_type.item, fixed_type.item) - ) - ): + if update := (fixed_type is None or (is_same_type(new_fixed_type, fixed_type))): fixed_type = new_fixed_type is_final = new_is_final return update for index in expr_indices: expr = node.operands[index] + proper_type = get_proper_type(self.lookup_type(expr)) if isinstance(expr, CallExpr) and is_type_call(expr): arg = expr.args[0] exprs_in_type_calls.append(arg) - typ = self.lookup_type(arg) - else: - proper_type = get_proper_type(self.lookup_type(expr)) - # get the range as though we were using isinstance - type_range = self.isinstance_type_range(proper_type) - # None range means this should not be used in comparison (eg tuple) - if type_range is None: - fixed_type = TypeRange(UninhabitedType(), True) - continue + elif ( + isinstance(expr, OpExpr) + or isinstance(proper_type, TupleType) + or is_named_instance(proper_type, "builtins.tuple") + ): + # not valid for type comparisons, but allowed for isinstance checks + fixed_type = UninhabitedType() + continue - if isinstance(expr, RefExpr) and isinstance(expr.node, TypeInfo): - if not update_fixed_type(type_range, expr.node.is_final): + type_range = self.get_isinstance_type(expr) + if type_range is not None: + target_types.append(type_range) + if ( + isinstance(expr, RefExpr) + and isinstance(expr.node, TypeInfo) + and len(type_range) == 1 + ): + if not update_fixed_type( + Instance( + expr.node, + [AnyType(TypeOfAny.special_form)] * len(expr.node.defn.type_vars), + ), + expr.node.is_final, + ): return None, {} - typ = type_range.item - - type_info = type_info_from_type(typ) - if type_info is not None: - target_types.append( - Instance( - type_info, - [AnyType(TypeOfAny.special_form)] * len(type_info.defn.type_vars), - ) - ) if not exprs_in_type_calls: return {}, {} - if target_types: - least_type: Type | None = target_types[0] - for target_type in target_types[1:]: - if fixed_type is None: - # intersect types if fixed type doesn't need keeping - least_type = self.intersect_instances( - (cast(Instance, least_type), target_type), [] # what to do with errors? - ) - else: - # otherwise, be safe and use meet - least_type = meet_types(cast(Type, least_type), target_type) - if least_type is None: - break - elif fixed_type: - least_type = fixed_type.item - else: - # no bounds means no inference can be made - return {}, {} - - # if the type differs from the fixed type, comparison cannot succeed - if least_type is None or ( - fixed_type is not None and not is_same_type(least_type, fixed_type.item) - ): - return None, {} - - shared_type = [TypeRange(least_type, not is_final)] - - if_maps: list[TypeMap] = [] - else_maps: list[TypeMap] = [] + if_maps = [] + else_maps = [] for expr in exprs_in_type_calls: - if_map, else_map = conditional_types_to_typemaps( - expr, - *self.conditional_types_with_intersection( - self.lookup_type(expr), shared_type, expr - ), - ) + expr_type = get_proper_type(self.lookup_type(expr)) + for type_range in target_types: + new_expr_type, _ = self.conditional_types_with_intersection( + expr_type, type_range, expr + ) + if new_expr_type is not None: + new_expr_type = get_proper_type(new_expr_type) + if isinstance(expr_type, AnyType): + expr_type = new_expr_type + elif not isinstance(new_expr_type, AnyType): + expr_type = meet_types(expr_type, new_expr_type) + _, else_map = conditional_types_to_typemaps( + expr, + *self.conditional_types_with_intersection( + (self.lookup_type(expr)), (type_range), expr + ), + ) + else_maps.append(else_map) + if fixed_type and expr_type is not None: + expr_type = meet_types(expr_type, fixed_type) + + if_map, _ = conditional_types_to_typemaps(expr, expr_type, None) if_maps.append(if_map) - else_maps.append(else_map) - def combine_maps(list_maps: list[TypeMap]) -> TypeMap: - """Combine all typemaps in list_maps into one typemap""" - if all(m is None for m in list_maps): - return None - result_map = {} - for d in list_maps: - if d is not None: - result_map.update(d) - return result_map - - if_map = combine_maps(if_maps) - # type(x) == T is only true when x has the same type as T, meaning - # that it can be false if x is an instance of a subclass of T. That means - # we can't do any narrowing in the else case unless T is final, in which - # case T can't be subclassed + if_map = functools.reduce(and_conditional_maps, if_maps) if is_final: - else_map = combine_maps(else_maps) + else_map = functools.reduce(or_conditional_maps, else_maps) else: else_map = {} return if_map, else_map diff --git a/test-data/unit/check-isinstance.test b/test-data/unit/check-isinstance.test index 504d635d6c66..bb2fe38aa381 100644 --- a/test-data/unit/check-isinstance.test +++ b/test-data/unit/check-isinstance.test @@ -2772,7 +2772,7 @@ x: X = z y: Y = z if type(x) is type(y): reveal_type(x) # N: Revealed type is "__main__." - reveal_type(y) # N: Revealed type is "__main__." + reveal_type(y) # N: Revealed type is "__main__." x.y + y.x if isinstance(x, type(y)) and isinstance(y, type(x)): @@ -2788,10 +2788,9 @@ from typing import Union y: str if type(y) is int: - y # E: Statement is unreachable + y else: reveal_type(y) # N: Revealed type is "builtins.str" -[builtins fixtures/isinstance.pyi] [case testTypeEqualsCheckUsingIsNonOverlappingChild-xfail] # flags: --warn-unreachable From df9f69516de765fc76d2c7a25c84affc4fd7ddce Mon Sep 17 00:00:00 2001 From: George Ogden Date: Mon, 24 Nov 2025 17:48:08 +0000 Subject: [PATCH 12/12] Use narrow_declared_type instead of meet_types for type narrowing --- mypy/checker.py | 27 +++++++++++++++++---------- test-data/unit/check-narrowing.test | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 1d25f089fc67..28a10c9d4f9c 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -48,7 +48,12 @@ from mypy.expandtype import expand_type from mypy.literals import Key, extract_var_from_literal_hash, literal, literal_hash from mypy.maptype import map_instance_to_supertype -from mypy.meet import is_overlapping_erased_types, is_overlapping_types, meet_types +from mypy.meet import ( + is_overlapping_erased_types, + is_overlapping_types, + meet_types, + narrow_declared_type, +) from mypy.message_registry import ErrorMessage from mypy.messages import ( SUGGESTED_TEST_FIXTURES, @@ -6292,17 +6297,18 @@ def update_fixed_type(new_fixed_type: Type, new_is_final: bool) -> bool: if_maps = [] else_maps = [] for expr in exprs_in_type_calls: - expr_type = get_proper_type(self.lookup_type(expr)) + expr_type: Type = get_proper_type(self.lookup_type(expr)) for type_range in target_types: - new_expr_type, _ = self.conditional_types_with_intersection( + restriction, _ = self.conditional_types_with_intersection( expr_type, type_range, expr ) - if new_expr_type is not None: - new_expr_type = get_proper_type(new_expr_type) - if isinstance(expr_type, AnyType): - expr_type = new_expr_type - elif not isinstance(new_expr_type, AnyType): - expr_type = meet_types(expr_type, new_expr_type) + if restriction is not None: + narrowed_type = get_proper_type(narrow_declared_type(expr_type, restriction)) + # Cannot be guaranteed that this is unreachable, so use fallback type. + if isinstance(narrowed_type, UninhabitedType): + expr_type = restriction + else: + expr_type = narrowed_type _, else_map = conditional_types_to_typemaps( expr, *self.conditional_types_with_intersection( @@ -6310,8 +6316,9 @@ def update_fixed_type(new_fixed_type: Type, new_is_final: bool) -> bool: ), ) else_maps.append(else_map) + if fixed_type and expr_type is not None: - expr_type = meet_types(expr_type, fixed_type) + expr_type = narrow_declared_type(expr_type, fixed_type) if_map, _ = conditional_types_to_typemaps(expr, expr_type, None) if_maps.append(if_map) diff --git a/test-data/unit/check-narrowing.test b/test-data/unit/check-narrowing.test index 00d33c86414f..acf2d496204d 100644 --- a/test-data/unit/check-narrowing.test +++ b/test-data/unit/check-narrowing.test @@ -1262,7 +1262,7 @@ def f(t: Type[C]) -> None: if type(t) is M: reveal_type(t) # N: Revealed type is "type[__main__.C]" else: - reveal_type(t) # N: Revealed type is "type[__main__.C]" + reveal_type(t) # N: Revealed type is "type[__main__.C]" if type(t) is not M: reveal_type(t) # N: Revealed type is "type[__main__.C]" else: