From cf5725c90d5bd27b13dfe73b15c4657294ca78df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Thu, 28 May 2026 14:03:33 +0300 Subject: [PATCH] Preserve type arguments when narrowing with `isinstance(x, type)` Narrowing a union such as `type[int] | str` with `isinstance(x, type)` collapsed the class object to bare `type`, dropping the `int` argument (issue #3585). The shared `unwrap_class_object_silently` unwrapped `type` to a nominal `ClassType(type)`, which does not intersect cleanly with the type-form representation of `type[int]`, so the argument was lost. Derive the isinstance target from the value being narrowed instead. When the value is already a type expression (`type[...]` or `TypeForm[...]`), narrow to `type[inner]`: concrete arguments like `type[int]` stay precise and gradual `type[Any]` stays gradual, matching pyright and mypy. Other values fall back to the nominal `ClassType(type)`, which narrows `object` to `type` and non-class instances to `Never`. Scope the special case to the isinstance path. `narrow_issubclass` shares `unwrap_class_object_silently` but untypes its operand to the instance level, so it relies on the nominal `ClassType(type)` to keep correct subclass semantics; routing the type-form target only through `unwrap_isinstance_target` leaves issubclass narrowing unchanged. Fixes #3585 --- pyrefly/lib/alt/narrow.rs | 12 ++++ pyrefly/lib/test/narrow.rs | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/pyrefly/lib/alt/narrow.rs b/pyrefly/lib/alt/narrow.rs index 39a3ed23c6..b30ce859ca 100644 --- a/pyrefly/lib/alt/narrow.rs +++ b/pyrefly/lib/alt/narrow.rs @@ -437,6 +437,18 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { }; if narrow_heterogeneous_tuple { Some(self.instantiate_type_var_tuple()) + } else if matches!(right, Type::ClassDef(c) if c == self.stdlib.builtins_type().class_object()) + { + // `isinstance(x, type)` narrows `x` to its class-object part. When `x` is already a + // type-expression value, that part is `type[inner]`: `type[int]` stays precise and + // gradual `type[Any]` stays gradual. + match left { + Type::Type(_) => Some((TParams::empty(), left.clone())), + Type::TypeForm(inner) => { + Some((TParams::empty(), self.heap.mk_type_of((**inner).clone()))) + } + _ => self.unwrap_class_object_silently(right), + } } else { self.unwrap_class_object_silently(right) } diff --git a/pyrefly/lib/test/narrow.rs b/pyrefly/lib/test/narrow.rs index a35777546d..e67d5af64d 100644 --- a/pyrefly/lib/test/narrow.rs +++ b/pyrefly/lib/test/narrow.rs @@ -3205,3 +3205,118 @@ def f(x: tuple[int, str] | int): assert_type(x, tuple[int, str]) "#, ); + +testcase!( + test_isinstance_type_preserves_type_arg, + r#" +from typing import reveal_type +def f(value: type[int] | str): + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[int] +"#, +); + +testcase!( + test_isinstance_type_then_issubclass_typeform, + r#" +from typing import reveal_type +from typing_extensions import TypeForm +def f(value: TypeForm[object]): + if isinstance(value, type) and issubclass(value, int): + reveal_type(value) # E: revealed type: type[int] +"#, +); + +testcase!( + test_isinstance_custom_metaclass_preserved, + r#" +from typing import reveal_type +class Meta(type): + meta_attr: int +def f(value: object): + if isinstance(value, Meta): + reveal_type(value) # E: revealed type: Meta + reveal_type(value.meta_attr) # E: revealed type: int +"#, +); + +testcase!( + test_isinstance_type_else_keeps_non_class, + r#" +from typing import reveal_type +def f(value: type[int] | str): + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[int] + else: + reveal_type(value) # E: revealed type: str +"#, +); + +testcase!( + test_isinstance_type_union_of_type_forms, + r#" +from typing import reveal_type +def f(value: type[int] | type[str] | bytes): + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[int | str] +"#, +); + +testcase!( + test_isinstance_type_preserves_subclass_arg, + r#" +from typing import reveal_type +def f(value: type[bool] | str): + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[bool] +"#, +); + +testcase!( + test_isinstance_type_tuple_with_class, + r#" +from typing import reveal_type +def f(value: type[int] | str | int): + if isinstance(value, (type, int)): + reveal_type(value) # E: revealed type: int | type[int] +"#, +); + +testcase!( + test_isinstance_bare_type_member, + r#" +from typing import reveal_type +def f(value: type | str): + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[Any] +"#, +); + +testcase!( + test_isinstance_type_keeps_gradual_inputs, + r#" +from typing import Any, reveal_type +def from_object(value: object) -> None: + if isinstance(value, type): + reveal_type(value) # E: revealed type: type + value(1, 2, 3) +def from_type_any(value: type[Any]) -> None: + if isinstance(value, type): + reveal_type(value) # E: revealed type: type[Any] + value(1, 2, 3) +"#, +); + +testcase!( + test_issubclass_type_keeps_subclass_semantics, + r#" +from typing import assert_type +def f(x: type[int] | type[type]) -> None: + # issubclass(x, type) asks whether x subclasses builtins.type (is a metaclass), + # so type[int] is excluded and only type[type] survives. + if issubclass(x, type): + assert_type(x, type[type]) + else: + assert_type(x, type[int]) +"#, +);