From 336fdc221503aceec31d1d21f42fe2375d69da36 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:15:00 -0400 Subject: [PATCH 01/11] [ty] Preserve generic members in overlapping patterns --- .../resources/mdtest/narrow/match.md | 22 +++++++++++ crates/ty_python_semantic/src/types/narrow.rs | 38 +++++++++++++++---- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index c6448eb947218..5e67074af8070 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -1076,6 +1076,10 @@ Two unrelated non-final classes can have a common subclass through multiple inhe successful pattern therefore preserves both class types: ```py +from typing import Generic, TypeVar + +OverlapT = TypeVar("OverlapT") + class OverlapCaptureA: ... class OverlapCaptureB: @@ -1101,6 +1105,24 @@ def test_match_class_capture_combines_overlapping_member_types( match value: case OverlapMemberB(member=item): reveal_type(item) # revealed: int | str + +class GenericOverlapA: + member: int + +class GenericOverlapB(Generic[OverlapT]): + member: OverlapT + +class GenericOverlapC(GenericOverlapB[str], GenericOverlapA): + member: str + +def test_match_generic_class_capture_preserves_possible_multiple_inheritance( + value: GenericOverlapA, +) -> None: + match value: + case GenericOverlapB(member=str() as item): + reveal_type(item) # revealed: str + reveal_type(value) # revealed: GenericOverlapA & Top[GenericOverlapB[Unknown]] + 1 + "x" # error: [unsupported-operator] ``` ## Class pattern captures from `Any` and `Unknown` diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 3e74d60ce1014..7421cefba8598 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1503,8 +1503,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { } } - if context.class.is_some_and(|pattern_class| { - pattern_class + if let Some(pattern_class) = context.class + && pattern_class .generic_context(self.db) .and_then(|generic_context| { pattern_class @@ -1517,11 +1517,35 @@ impl<'db> PatternSuccessAnalyzer<'db> { .ignore_possibly_undefined() }) .is_some_and(|ty| ty.has_typevar(self.db)) - }) { - // The pattern subclass's default specialization loses the type arguments from the - // subject's generic base. Prefer a member type already known from the subject; - // otherwise, do not treat the subclass's fallback as a declared type. - member_ty = Some(original_member_ty.unwrap_or_else(Type::unknown)); + { + let specializes_original_class = original_subject_ty + .nominal_class(self.db) + .is_some_and(|original_class| { + pattern_class + .default_specialization(self.db) + .is_subtype_of_class_literal( + self.db, + original_class.class_literal(self.db), + ) + }); + if specializes_original_class { + // The pattern subclass's default specialization loses the type arguments from + // the subject's generic base. Prefer a member type already known from the + // subject; otherwise, do not treat the subclass's fallback as a declared type. + member_ty = Some(original_member_ty.unwrap_or_else(Type::unknown)); + } else if let Some(pattern_member_ty) = context + .class_ty + .member(self.db, name.as_str()) + .place + .ignore_possibly_undefined() + { + // Unrelated classes can overlap through multiple inheritance, so retain the + // generic pattern class's member as a possible runtime value. + member_ty = Some(UnionType::from_elements( + self.db, + member_ty.into_iter().chain([pattern_member_ty]), + )); + } } member_ty.or_else(|| (!subject_is_final).then_some(Type::unknown())) }; From 9b391333ab8fd606b077166590f83a4f8b8bc4c1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:26:26 -0400 Subject: [PATCH 02/11] [ty] Preserve generic base pattern specialization --- .../resources/mdtest/narrow/match.md | 9 +++++++ crates/ty_python_semantic/src/types/narrow.rs | 27 +++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 5e67074af8070..e23f49f04c224 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -845,6 +845,7 @@ class GenericMemberBase(Generic[GenericPatternT]): item: GenericPatternT class GenericMemberChild(GenericMemberBase[GenericPatternT]): ... +class IntGenericMemberChild(GenericMemberBase[int]): ... def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: match value: @@ -870,6 +871,14 @@ def test_match_inherited_generic_subclass_capture( return item case _: raise ValueError + +def test_match_generic_base_capture_preserves_subject_specialization( + value: IntGenericMemberChild, +) -> None: + match value: + case GenericMemberBase(item=item): + reveal_type(item) # revealed: int + item.bit_length() ``` ## Positional class patterns diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 7421cefba8598..fb2c681cd95d5 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1518,20 +1518,25 @@ impl<'db> PatternSuccessAnalyzer<'db> { }) .is_some_and(|ty| ty.has_typevar(self.db)) { - let specializes_original_class = original_subject_ty + // For example, `Child[int]` and `Base[T]` share a generic hierarchy, so a `Base` + // pattern can reuse `int` from the subject. This does not infer `Child[int]` from + // a `Base[int]` subject. + let shares_generic_hierarchy = original_subject_ty .nominal_class(self.db) .is_some_and(|original_class| { - pattern_class - .default_specialization(self.db) - .is_subtype_of_class_literal( - self.db, - original_class.class_literal(self.db), - ) + let pattern_class = pattern_class.default_specialization(self.db); + pattern_class.is_subtype_of_class_literal( + self.db, + original_class.class_literal(self.db), + ) || original_class.is_subtype_of_class_literal( + self.db, + pattern_class.class_literal(self.db), + ) }); - if specializes_original_class { - // The pattern subclass's default specialization loses the type arguments from - // the subject's generic base. Prefer a member type already known from the - // subject; otherwise, do not treat the subclass's fallback as a declared type. + if shares_generic_hierarchy { + // The pattern class's default specialization loses type arguments known + // through the related subject type. Prefer the subject's member type; + // otherwise, do not treat the generic fallback as a declared type. member_ty = Some(original_member_ty.unwrap_or_else(Type::unknown)); } else if let Some(pattern_member_ty) = context .class_ty From 4f9f2f60a80efd8d194eed795d399fd9c1360fa7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:29:03 -0400 Subject: [PATCH 03/11] [ty] Split union-valued class patterns --- .../resources/mdtest/narrow/match.md | 20 ++++ crates/ty_python_semantic/src/types/narrow.rs | 104 +++++++++++++----- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index e23f49f04c224..b7d879a7809be 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -659,6 +659,8 @@ A class pattern can use a variable whose type is `type[Class]`. Both the subject use the instance type described by that annotation. ```py +from typing import Literal + class IndirectPattern: ... def test_match_indirect_class_pattern( @@ -669,6 +671,24 @@ def test_match_indirect_class_pattern( case PatternClass() as item: reveal_type(item) # revealed: IndirectPattern reveal_type(value) # revealed: IndirectPattern + +class IndirectIntPattern: + tag: Literal["int"] + payload: int + +class IndirectStrPattern: + tag: Literal["str"] + payload: str + +def test_match_union_class_pattern_preserves_member_correlation( + value: object, + PatternClass: type[IndirectIntPattern] | type[IndirectStrPattern], +) -> None: + match value: + case PatternClass(tag="int", payload=item): + reveal_type(item) # revealed: int + reveal_type(value) # revealed: IndirectIntPattern + item.bit_length() ``` ## Class pattern aliases diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index fb2c681cd95d5..47eaaa06cd0e4 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1583,43 +1583,91 @@ impl<'db> PatternSuccessAnalyzer<'db> { .collect() } + fn class_pattern_contexts( + &self, + kind: &ClassPatternPredicateKind<'db>, + ) -> SmallVec<[ClassPatternContext<'db>; 2]> { + let class_expr_ty = + infer_same_file_expression_type(self.db, kind.class, TypeContext::default()) + .resolve_type_alias(self.db); + let context = |class_expr_ty: Type<'db>| { + let class = class_expr_ty.as_class_literal(); + ClassPatternContext { + class, + class_ty: positive_class_pattern_type(self.db, class_expr_ty) + .unwrap_or_else(Type::object), + positional_sources: class.map_or_else( + || vec![ClassPatternPositionalSource::Unknown; kind.positional.len()], + |class| class_pattern_positional_sources(self.db, class, kind.positional.len()), + ), + } + }; + match class_expr_ty { + Type::Union(union) => union + .elements(self.db) + .iter() + .copied() + .map(context) + .collect(), + _ => smallvec![context(class_expr_ty)], + } + } + + fn class_pattern_arm( + &self, + kind: &ClassPatternPredicateKind<'db>, + context: &ClassPatternContext<'db>, + original_subject_ty: Type<'db>, + subject_ty: Type<'db>, + ) -> Option<(Type<'db>, Vec>)> { + let narrowed_subject_ty = + self.filter_class_pattern_subject_type(context.class, context.class_ty, subject_ty); + if narrowed_subject_ty.is_never() { + return None; + } + let arguments = self.class_pattern_arguments_for_arm( + kind, + context, + original_subject_ty, + narrowed_subject_ty, + )?; + Some((narrowed_subject_ty, arguments)) + } + fn analyze_successful_class_pattern( &self, kind: &ClassPatternPredicateKind<'db>, subject_ty: Type<'db>, ) -> PatternSuccessResult<'db> { - let class_expr_ty = - infer_same_file_expression_type(self.db, kind.class, TypeContext::default()); - let class = class_expr_ty.as_class_literal(); - let class_ty = - positive_class_pattern_type(self.db, class_expr_ty).unwrap_or_else(Type::object); - let context = ClassPatternContext { - class, - class_ty, - positional_sources: class.map_or_else( - || vec![ClassPatternPositionalSource::Unknown; kind.positional.len()], - |class| class_pattern_positional_sources(self.db, class, kind.positional.len()), - ), - }; + let mut matched_subject_types = UnionBuilder::new(self.db); + let mut binding_subject_types = UnionBuilder::new(self.db); + let mut bindings = BTreeMap::new(); + for context in self.class_pattern_contexts(kind) { + let result = + self.analyze_successful_class_pattern_for_context(kind, &context, subject_ty); + matched_subject_types.add_in_place(result.matched_subject_ty); + binding_subject_types.add_in_place(result.binding_subject_ty); + Self::merge_bindings(&mut bindings, result.bindings); + } + PatternSuccessResult { + matched_subject_ty: matched_subject_types.build(), + binding_subject_ty: binding_subject_types.build(), + bindings, + } + } + + fn analyze_successful_class_pattern_for_context( + &self, + kind: &ClassPatternPredicateKind<'db>, + context: &ClassPatternContext<'db>, + subject_ty: Type<'db>, + ) -> PatternSuccessResult<'db> { self.analyze_pattern_subject_arms( subject_ty, OriginalSubjectPreservation::EquivalentTypes, |analyzer, original_subject_ty, subject_ty| { - let narrowed_subject_ty = analyzer.filter_class_pattern_subject_type( - context.class, - context.class_ty, - subject_ty, - ); - if narrowed_subject_ty.is_never() { - return None; - } - - let arguments = analyzer.class_pattern_arguments_for_arm( - kind, - &context, - original_subject_ty, - narrowed_subject_ty, - )?; + let (narrowed_subject_ty, arguments) = + analyzer.class_pattern_arm(kind, context, original_subject_ty, subject_ty)?; let mut matched_subject_ty = narrowed_subject_ty; let mut binding_subject_ty = narrowed_subject_ty; let mut bindings = BTreeMap::new(); From fa94140c0576d9eced5ddbcd382c0fdf4397ea19 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:30:55 -0400 Subject: [PATCH 04/11] [ty] Extract mapping entries from narrowed subjects --- .../resources/mdtest/narrow/match.md | 11 +++- crates/ty_python_semantic/src/types/narrow.rs | 50 +++++++++++-------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index b7d879a7809be..5436caadeae3b 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -1206,6 +1206,12 @@ def test_match_dict_alias_preserves_concrete_type(value: dict[str, int]) -> None case {"item": item, **rest} as whole: reveal_type(whole) # revealed: dict[str, int] +def test_match_object_mapping_capture(value: object) -> None: + match value: + case {"item": item}: + reveal_type(item) # revealed: object + item.missing # error: [unresolved-attribute] + class CustomGet(Mapping[str, int | str]): def __getitem__(self, key: str) -> int: return 1 @@ -2220,7 +2226,8 @@ def match_named_expression_subject_capture(value: tuple[int]) -> None: Pattern captures can affect the type of a later match subject, including through a loop or a function defined before the capture. Direct, sequence, class, and match-self captures resolve to a -concrete type. A mapping capture conservatively retains `Unknown` from cycle recovery. +concrete type. A mapping capture from a recursive subject widens to `object` through the guaranteed +mapping interface. ```py def match_loop_carried_capture(flag: bool, x: int) -> None: @@ -2251,7 +2258,7 @@ def match_loop_carried_mapping_capture(flag: bool) -> None: while flag: match x: case {"value": x}: - reveal_type(x) # revealed: int | Unknown + reveal_type(x) # revealed: object def match_loop_carried_match_self_capture(flag: bool, x: int) -> None: while flag: diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 47eaaa06cd0e4..ca14fb58cdefb 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1779,37 +1779,43 @@ impl<'db> PatternSuccessAnalyzer<'db> { false } + fn mapping_pattern_key_types(&self, kind: &MappingPatternPredicateKind<'db>) -> Vec> { + kind.entries + .iter() + .map(|entry| { + infer_same_file_expression_type(self.db, entry.key, TypeContext::default()) + }) + .collect() + } + + fn mapping_pattern_arm( + &self, + subject_ty: Type<'db>, + key_types: &[Type<'db>], + ) -> Option<(Type<'db>, Vec>)> { + let narrowed_subject_ty = self.intersect_types(subject_ty, mapping_pattern_type(self.db)); + if narrowed_subject_ty.is_never() { + return None; + } + let value_types = key_types + .iter() + .map(|key_ty| self.mapping_pattern_value_type_for_arm(narrowed_subject_ty, *key_ty)) + .collect::>>()?; + Some((narrowed_subject_ty, value_types)) + } + fn analyze_successful_mapping_pattern( &self, kind: &MappingPatternPredicateKind<'db>, subject_ty: Type<'db>, ) -> PatternSuccessResult<'db> { - let key_types: Vec<_> = kind - .entries - .iter() - .map(|entry| { - infer_same_file_expression_type(self.db, entry.key, TypeContext::default()) - }) - .collect(); + let key_types = self.mapping_pattern_key_types(kind); self.analyze_pattern_subject_arms( subject_ty, OriginalSubjectPreservation::EquivalentTypes, |analyzer, _, subject_ty| { - let narrowed_subject_ty = - analyzer.intersect_types(subject_ty, mapping_pattern_type(analyzer.db)); - if narrowed_subject_ty.is_never() { - return None; - } - - let value_types: Option> = kind - .entries - .iter() - .zip(&key_types) - .map(|(_, key_ty)| { - analyzer.mapping_pattern_value_type_for_arm(subject_ty, *key_ty) - }) - .collect(); - let value_types = value_types?; + let (narrowed_subject_ty, value_types) = + analyzer.mapping_pattern_arm(subject_ty, &key_types)?; let mut bindings = BTreeMap::new(); for (entry, value_ty) in kind.entries.iter().zip(value_types) { let mut child = analyzer.analyze_successful_pattern(&entry.pattern, value_ty); From 86bb1569cd0974730e5b0fe3b3b14a69a1d52db0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:45:53 -0400 Subject: [PATCH 05/11] [ty] Preserve direct generic pattern members --- .../resources/mdtest/narrow/match.md | 20 ++++++++++++++----- crates/ty_python_semantic/src/types/narrow.rs | 15 +++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 5436caadeae3b..14a4f8c60d675 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -847,11 +847,12 @@ def test_incompatible_declared_class_capture(value: PatternBox[int]) -> None: We do not yet infer a generic subclass's specialization from its base class. In the first two examples, ty therefore cannot infer `GenericPatternChild[int]` from `GenericPatternBase[int]`, so -attributes declared only on the subclass are `Unknown`. Attributes inherited from the generic base -class can still use the subject's specialization, as shown in the final example. +type parameters in attributes declared only on the subclass become `Unknown`, while their known +generic structure is preserved. Attributes inherited from the generic base class can still use the +subject's specialization. ```py -from typing import Generic, TypeVar +from typing import final, Generic, TypeVar GenericPatternT = TypeVar("GenericPatternT") @@ -867,6 +868,10 @@ class GenericMemberBase(Generic[GenericPatternT]): class GenericMemberChild(GenericMemberBase[GenericPatternT]): ... class IntGenericMemberChild(GenericMemberBase[int]): ... +@final +class FinalGenericPatternBox(Generic[GenericPatternT]): + value: list[GenericPatternT] + def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: match value: case GenericPatternChild(item=item): @@ -876,8 +881,7 @@ def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) -> list[int]: match value: case GenericPatternChild(items=items): - # TODO: This should be `list[int]` once generic subclass specialization is supported. - reveal_type(items) # revealed: Unknown + reveal_type(items) # revealed: list[Unknown] return items return [] @@ -899,6 +903,12 @@ def test_match_generic_base_capture_preserves_subject_specialization( case GenericMemberBase(item=item): reveal_type(item) # revealed: int item.bit_length() + +def test_match_direct_generic_pattern_preserves_declared_member(value: object) -> None: + match value: + case FinalGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Never + reveal_type(value) # revealed: Never ``` ## Positional class patterns diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index ca14fb58cdefb..83481ffefac49 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1518,6 +1518,11 @@ impl<'db> PatternSuccessAnalyzer<'db> { }) .is_some_and(|ty| ty.has_typevar(self.db)) { + let default_pattern_member_ty = + Type::instance(self.db, pattern_class.default_specialization(self.db)) + .member(self.db, name.as_str()) + .place + .ignore_possibly_undefined(); // For example, `Child[int]` and `Base[T]` share a generic hierarchy, so a `Base` // pattern can reuse `int` from the subject. This does not infer `Child[int]` from // a `Base[int]` subject. @@ -1535,9 +1540,13 @@ impl<'db> PatternSuccessAnalyzer<'db> { }); if shares_generic_hierarchy { // The pattern class's default specialization loses type arguments known - // through the related subject type. Prefer the subject's member type; - // otherwise, do not treat the generic fallback as a declared type. - member_ty = Some(original_member_ty.unwrap_or_else(Type::unknown)); + // through the related subject type. Prefer the subject's member type when it + // exists, but retain a member declared only by the pattern class. + member_ty = Some( + original_member_ty + .or(default_pattern_member_ty) + .unwrap_or_else(Type::unknown), + ); } else if let Some(pattern_member_ty) = context .class_ty .member(self.db, name.as_str()) From dd81a3a0717cd0a244593d2a77d6a830c61b530c Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 21:51:26 -0400 Subject: [PATCH 06/11] [ty] Avoid stale sequence facts in OR bindings --- .../resources/mdtest/narrow/match.md | 13 +++++++++++++ crates/ty_python_semantic/src/types.rs | 4 ++-- crates/ty_python_semantic/src/types/narrow.rs | 6 +++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 14a4f8c60d675..6a220f224996f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -507,6 +507,10 @@ class TextValue: class StringMapping(TypedDict): value: str +@final +class MutableOrBox: + value: int = 0 + def class_or_sequence_binding(value: TextValue | tuple[int]) -> None: match value: case TextValue(value=item) | [item]: @@ -516,6 +520,15 @@ def mapping_or_singleton_binding(value: StringMapping | None) -> None: match value: case {"value": item} | (None as item): reveal_type(item) # revealed: str | None + +def mixed_or_binding_does_not_keep_failed_sequence_facts( + value: list[int] | MutableOrBox, +) -> None: + match value: + case [item] | MutableOrBox(value=item) | item: + if isinstance(item, list): + item.clear() + reveal_type(item) # revealed: list[int] ``` ## Declared pattern captures diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index cee621e56305a..f4ab2d72d371f 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -38,8 +38,8 @@ pub(crate) use self::match_pattern::{ ClassPatternPositionalSource, callable_pattern_type, class_pattern_positional_sources, definite_match_pattern_type, definite_match_pattern_type_for_subject, exact_sequence_pattern_type, mapping_pattern_type, pattern_binding_fallthrough_type, - pattern_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, - starred_sequence_pattern_type, typed_dict_matches_class_pattern, + sequence_pattern_type_builder, singleton_pattern_type, starred_sequence_pattern_type, + typed_dict_matches_class_pattern, }; pub(crate) use self::relation_error::{ErrorContext, ErrorContextTree, ParameterDescription}; use self::set_theoretic::KnownUnion; diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 83481ffefac49..78b38d32e054e 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -18,8 +18,8 @@ use crate::types::{ Type, TypeContext, TypeVarBoundOrConstraints, UnionBuilder, callable_pattern_type, class_pattern_positional_sources, definite_match_pattern_type_for_subject, exact_sequence_pattern_type, infer_expression_types, mapping_pattern_type, - pattern_binding_fallthrough_type, pattern_fallthrough_type, sequence_pattern_type_builder, - singleton_pattern_type, starred_sequence_pattern_type, typed_dict_matches_class_pattern, + pattern_binding_fallthrough_type, sequence_pattern_type_builder, singleton_pattern_type, + starred_sequence_pattern_type, typed_dict_matches_class_pattern, }; use ty_python_core::expression::Expression; use ty_python_core::frozen::FrozenMap; @@ -1394,7 +1394,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { for pattern in patterns { remaining_subject_ty = - pattern_fallthrough_type(self.db, previous_pattern, remaining_subject_ty); + pattern_binding_fallthrough_type(self.db, previous_pattern, remaining_subject_ty); let alternative = self.analyze_successful_pattern(pattern, remaining_subject_ty); matched_subject_types.add_in_place(alternative.matched_subject_ty); binding_subject_types.add_in_place(alternative.binding_subject_ty); From dc572a95139d4589b9d7085afdee1a49283b8d92 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 22:20:14 -0400 Subject: [PATCH 07/11] [ty] Preserve default generic overlap members --- .../resources/mdtest/narrow/match.md | 17 ++++++++++++++++- crates/ty_python_semantic/src/types/narrow.rs | 7 +------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 6a220f224996f..1e40044e5c011 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -1167,14 +1167,29 @@ class GenericOverlapB(Generic[OverlapT]): class GenericOverlapC(GenericOverlapB[str], GenericOverlapA): member: str +class GenericListOverlapA: ... + +class GenericListOverlapB(Generic[OverlapT]): + values: list[OverlapT] + +class GenericListOverlapC(GenericListOverlapA, GenericListOverlapB[int]): ... + def test_match_generic_class_capture_preserves_possible_multiple_inheritance( value: GenericOverlapA, ) -> None: match value: case GenericOverlapB(member=str() as item): - reveal_type(item) # revealed: str + reveal_type(item) # revealed: Unknown & str reveal_type(value) # revealed: GenericOverlapA & Top[GenericOverlapB[Unknown]] 1 + "x" # error: [unsupported-operator] + +def test_match_generic_container_member_preserves_unknown_specialization( + value: GenericListOverlapA, +) -> None: + match value: + case GenericListOverlapB(values=items): + for item in items: + reveal_type(item) # revealed: Unknown ``` ## Class pattern captures from `Any` and `Unknown` diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 78b38d32e054e..41b877c22ba83 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1547,12 +1547,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { .or(default_pattern_member_ty) .unwrap_or_else(Type::unknown), ); - } else if let Some(pattern_member_ty) = context - .class_ty - .member(self.db, name.as_str()) - .place - .ignore_possibly_undefined() - { + } else if let Some(pattern_member_ty) = default_pattern_member_ty { // Unrelated classes can overlap through multiple inheritance, so retain the // generic pattern class's member as a possible runtime value. member_ty = Some(UnionType::from_elements( From df8aa85ce43fed21986444664bf5ce0acea3a063 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Thu, 25 Jun 2026 22:32:57 -0400 Subject: [PATCH 08/11] [ty] Preserve compatible overlapping class members --- .../resources/mdtest/narrow/match.md | 17 ++++++++++++++++- crates/ty_python_semantic/src/types/narrow.rs | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 1e40044e5c011..1e3a956996414 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -1151,6 +1151,12 @@ class OverlapMemberA: class OverlapMemberB: member: str +class CompatibleOverlapMemberA: + member: object = "x" + +class CompatibleOverlapMemberB: + member: int = 1 + def test_match_class_capture_combines_overlapping_member_types( value: OverlapMemberA, ) -> None: @@ -1158,6 +1164,15 @@ def test_match_class_capture_combines_overlapping_member_types( case OverlapMemberB(member=item): reveal_type(item) # revealed: int | str +def test_match_class_capture_preserves_compatible_overlapping_member_types( + value: CompatibleOverlapMemberA, +) -> None: + match value: + case CompatibleOverlapMemberB(member=str() as item): + reveal_type(item) # revealed: str + # revealed: CompatibleOverlapMemberA & CompatibleOverlapMemberB + reveal_type(value) + class GenericOverlapA: member: int @@ -1179,7 +1194,7 @@ def test_match_generic_class_capture_preserves_possible_multiple_inheritance( ) -> None: match value: case GenericOverlapB(member=str() as item): - reveal_type(item) # revealed: Unknown & str + reveal_type(item) # revealed: str reveal_type(value) # revealed: GenericOverlapA & Top[GenericOverlapB[Unknown]] 1 + "x" # error: [unsupported-operator] diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 41b877c22ba83..5b7d6755f2b79 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1483,7 +1483,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { .ignore_possibly_undefined(); let place = subject_ty.member(self.db, name.as_str()).place; let mut member_ty = place.ignore_possibly_undefined(); - if member_ty.is_some_and(|ty| ty.is_never()) + if original_subject_ty.nominal_class(self.db).is_some() && let Type::Intersection(intersection) = subject_ty { let overlapping_member_ty = UnionType::from_elements( From abff86de399c55a37f232664941fc0b3735f1a46 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 26 Jun 2026 06:34:13 -0400 Subject: [PATCH 09/11] [ty] Ignore generic defaults in class patterns --- .../resources/mdtest/narrow/match.md | 13 ++++++++++++- crates/ty_python_semantic/src/types/narrow.rs | 12 ++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 1e3a956996414..739c01adc6ef7 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -865,9 +865,11 @@ generic structure is preserved. Attributes inherited from the generic base class subject's specialization. ```py -from typing import final, Generic, TypeVar +from typing import final, Generic +from typing_extensions import TypeVar GenericPatternT = TypeVar("GenericPatternT") +DefaultGenericPatternT = TypeVar("DefaultGenericPatternT", default=str) class GenericPatternBase(Generic[GenericPatternT]): ... @@ -885,6 +887,9 @@ class IntGenericMemberChild(GenericMemberBase[int]): ... class FinalGenericPatternBox(Generic[GenericPatternT]): value: list[GenericPatternT] +class DefaultGenericPatternBox(Generic[DefaultGenericPatternT]): + value: DefaultGenericPatternT + def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: match value: case GenericPatternChild(item=item): @@ -922,6 +927,12 @@ def test_match_direct_generic_pattern_preserves_declared_member(value: object) - case FinalGenericPatternBox(value=int() as item): reveal_type(item) # revealed: Never reveal_type(value) # revealed: Never + +def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: + match value: + case DefaultGenericPatternBox(value=int() as item): + reveal_type(item) # revealed: Unknown & int + 1 + "x" # error: [unsupported-operator] ``` ## Positional class patterns diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index 5b7d6755f2b79..e2daed6ed7e11 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1518,8 +1518,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { }) .is_some_and(|ty| ty.has_typevar(self.db)) { - let default_pattern_member_ty = - Type::instance(self.db, pattern_class.default_specialization(self.db)) + let unknown_pattern_member_ty = + Type::instance(self.db, pattern_class.unknown_specialization(self.db)) .member(self.db, name.as_str()) .place .ignore_possibly_undefined(); @@ -1529,7 +1529,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { let shares_generic_hierarchy = original_subject_ty .nominal_class(self.db) .is_some_and(|original_class| { - let pattern_class = pattern_class.default_specialization(self.db); + let pattern_class = pattern_class.unknown_specialization(self.db); pattern_class.is_subtype_of_class_literal( self.db, original_class.class_literal(self.db), @@ -1539,15 +1539,15 @@ impl<'db> PatternSuccessAnalyzer<'db> { ) }); if shares_generic_hierarchy { - // The pattern class's default specialization loses type arguments known + // The pattern class's unknown specialization loses type arguments known // through the related subject type. Prefer the subject's member type when it // exists, but retain a member declared only by the pattern class. member_ty = Some( original_member_ty - .or(default_pattern_member_ty) + .or(unknown_pattern_member_ty) .unwrap_or_else(Type::unknown), ); - } else if let Some(pattern_member_ty) = default_pattern_member_ty { + } else if let Some(pattern_member_ty) = unknown_pattern_member_ty { // Unrelated classes can overlap through multiple inheritance, so retain the // generic pattern class's member as a possible runtime value. member_ty = Some(UnionType::from_elements( From dc493402008f23cb41d1e8ba0264074c0c1230ec Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 26 Jun 2026 12:23:44 -0400 Subject: [PATCH 10/11] [ty] Simplify generic pattern member lookup --- crates/ty_python_semantic/src/types/narrow.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index e2daed6ed7e11..c656ac445771d 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1518,24 +1518,23 @@ impl<'db> PatternSuccessAnalyzer<'db> { }) .is_some_and(|ty| ty.has_typevar(self.db)) { - let unknown_pattern_member_ty = - Type::instance(self.db, pattern_class.unknown_specialization(self.db)) - .member(self.db, name.as_str()) - .place - .ignore_possibly_undefined(); + let unknown_pattern_class = pattern_class.unknown_specialization(self.db); + let unknown_pattern_member_ty = Type::instance(self.db, unknown_pattern_class) + .member(self.db, name.as_str()) + .place + .ignore_possibly_undefined(); // For example, `Child[int]` and `Base[T]` share a generic hierarchy, so a `Base` // pattern can reuse `int` from the subject. This does not infer `Child[int]` from // a `Base[int]` subject. let shares_generic_hierarchy = original_subject_ty .nominal_class(self.db) .is_some_and(|original_class| { - let pattern_class = pattern_class.unknown_specialization(self.db); - pattern_class.is_subtype_of_class_literal( + unknown_pattern_class.is_subtype_of_class_literal( self.db, original_class.class_literal(self.db), ) || original_class.is_subtype_of_class_literal( self.db, - pattern_class.class_literal(self.db), + unknown_pattern_class.class_literal(self.db), ) }); if shares_generic_hierarchy { From 8335c7e48124a18a33688f27b0d368e2a7b1ef41 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 26 Jun 2026 12:23:51 -0400 Subject: [PATCH 11/11] [ty] Clarify match pattern binding analysis --- .../resources/mdtest/narrow/match.md | 64 ++++++++++--------- crates/ty_python_semantic/src/types/narrow.rs | 6 +- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/narrow/match.md b/crates/ty_python_semantic/resources/mdtest/narrow/match.md index 739c01adc6ef7..6e9b50e8fb37f 100644 --- a/crates/ty_python_semantic/resources/mdtest/narrow/match.md +++ b/crates/ty_python_semantic/resources/mdtest/narrow/match.md @@ -507,10 +507,6 @@ class TextValue: class StringMapping(TypedDict): value: str -@final -class MutableOrBox: - value: int = 0 - def class_or_sequence_binding(value: TextValue | tuple[int]) -> None: match value: case TextValue(value=item) | [item]: @@ -520,15 +516,29 @@ def mapping_or_singleton_binding(value: StringMapping | None) -> None: match value: case {"value": item} | (None as item): reveal_type(item) # revealed: str | None +``` + +The first two alternatives bind an `int`. A list that does not contain exactly one element reaches +the final capture instead. If that list is later changed to contain one element, the same sequence +pattern must be able to match it: + +```py +@final +class MutableOrBox: + value: int = 0 -def mixed_or_binding_does_not_keep_failed_sequence_facts( +def failed_sequence_alternative_does_not_narrow_later_capture( value: list[int] | MutableOrBox, ) -> None: match value: case [item] | MutableOrBox(value=item) | item: + reveal_type(item) # revealed: int | list[int] if isinstance(item, list): item.clear() - reveal_type(item) # revealed: list[int] + item.append(1) + match item: + case [only]: + reveal_type(item) # revealed: list[int] ``` ## Declared pattern captures @@ -693,15 +703,13 @@ class IndirectStrPattern: tag: Literal["str"] payload: str -def test_match_union_class_pattern_preserves_member_correlation( +def test_union_class_pattern_uses_members_from_matching_class( value: object, PatternClass: type[IndirectIntPattern] | type[IndirectStrPattern], ) -> None: match value: case PatternClass(tag="int", payload=item): reveal_type(item) # revealed: int - reveal_type(value) # revealed: IndirectIntPattern - item.bit_length() ``` ## Class pattern aliases @@ -858,11 +866,11 @@ def test_incompatible_declared_class_capture(value: PatternBox[int]) -> None: ## Generic subclass captures -We do not yet infer a generic subclass's specialization from its base class. In the first two -examples, ty therefore cannot infer `GenericPatternChild[int]` from `GenericPatternBase[int]`, so -type parameters in attributes declared only on the subclass become `Unknown`, while their known -generic structure is preserved. Attributes inherited from the generic base class can still use the -subject's specialization. +We do not yet infer a generic subclass's type arguments from its base class. Attributes declared +only on the subclass therefore use `Unknown` for those arguments, but still retain types such as +`list[Unknown]`. Attributes inherited from a generic base can use type arguments from the subject. +When the subject does not provide type arguments, members declared by the pattern class use +`Unknown`; a type parameter default does not restrict which instances match at runtime. ```py from typing import final, Generic @@ -899,6 +907,7 @@ def test_match_generic_subclass_capture(value: GenericPatternBase[int]) -> None: def test_match_nested_generic_subclass_capture(value: GenericPatternBase[int]) -> list[int]: match value: case GenericPatternChild(items=items): + # TODO: This should be `list[int]` once generic subclass specialization is supported. reveal_type(items) # revealed: list[Unknown] return items return [] @@ -920,19 +929,16 @@ def test_match_generic_base_capture_preserves_subject_specialization( match value: case GenericMemberBase(item=item): reveal_type(item) # revealed: int - item.bit_length() def test_match_direct_generic_pattern_preserves_declared_member(value: object) -> None: match value: case FinalGenericPatternBox(value=int() as item): reveal_type(item) # revealed: Never - reveal_type(value) # revealed: Never def test_match_generic_pattern_ignores_typevar_default(value: object) -> None: match value: case DefaultGenericPatternBox(value=int() as item): reveal_type(item) # revealed: Unknown & int - 1 + "x" # error: [unsupported-operator] ``` ## Positional class patterns @@ -1136,7 +1142,9 @@ def builtin_positional_pattern_refines_subject_alias(value: bool) -> Literal[Tru ## Overlapping class patterns Two unrelated non-final classes can have a common subclass through multiple inheritance. The -successful pattern therefore preserves both class types: +successful pattern therefore preserves both class types. Attributes from both bases remain possible, +even when one annotation is broader than the other. For a generic pattern class whose type arguments +are not known from the subject, its attributes use `Unknown`. ```py from typing import Generic, TypeVar @@ -1181,8 +1189,6 @@ def test_match_class_capture_preserves_compatible_overlapping_member_types( match value: case CompatibleOverlapMemberB(member=str() as item): reveal_type(item) # revealed: str - # revealed: CompatibleOverlapMemberA & CompatibleOverlapMemberB - reveal_type(value) class GenericOverlapA: member: int @@ -1206,10 +1212,8 @@ def test_match_generic_class_capture_preserves_possible_multiple_inheritance( match value: case GenericOverlapB(member=str() as item): reveal_type(item) # revealed: str - reveal_type(value) # revealed: GenericOverlapA & Top[GenericOverlapB[Unknown]] - 1 + "x" # error: [unsupported-operator] -def test_match_generic_container_member_preserves_unknown_specialization( +def test_match_generic_container_member_keeps_loop_reachable( value: GenericListOverlapA, ) -> None: match value: @@ -1245,8 +1249,9 @@ def test_match_gradual_class_captures(any_value: Any, unknown_value: Unknown) -> Python reads an explicit mapping entry by calling `get` with a sentinel. A custom `get` method can therefore produce a broader type than `__getitem__`; the sentinel's type is treated as `object` when calling a custom override. The key type of an ordinary `Mapping` does not prove that another key is -absent because a custom `get` method may accept a broader set of keys. `**rest` is always a new -`dict` containing the unmatched items. +absent because a custom `get` method may accept a broader set of keys. When the subject is only +known as `object`, a successful mapping pattern gives its entries the type `object`, not `Unknown`. +`**rest` is always a new `dict` containing the unmatched items. ```py from collections.abc import Iterator, Mapping @@ -1270,11 +1275,10 @@ def test_match_dict_alias_preserves_concrete_type(value: dict[str, int]) -> None case {"item": item, **rest} as whole: reveal_type(whole) # revealed: dict[str, int] -def test_match_object_mapping_capture(value: object) -> None: +def test_match_object_mapping_entry_type(value: object) -> None: match value: case {"item": item}: reveal_type(item) # revealed: object - item.missing # error: [unresolved-attribute] class CustomGet(Mapping[str, int | str]): def __getitem__(self, key: str) -> int: @@ -2289,9 +2293,9 @@ def match_named_expression_subject_capture(value: tuple[int]) -> None: ## Cycles in pattern binding types Pattern captures can affect the type of a later match subject, including through a loop or a -function defined before the capture. Direct, sequence, class, and match-self captures resolve to a -concrete type. A mapping capture from a recursive subject widens to `object` through the guaranteed -mapping interface. +function defined before the capture. Direct, sequence, class, and built-in positional captures +resolve to a concrete type. For a mapping capture, the recursive subject is known only to be a +mapping, so its entry type is `object`. ```py def match_loop_carried_capture(flag: bool, x: int) -> None: diff --git a/crates/ty_python_semantic/src/types/narrow.rs b/crates/ty_python_semantic/src/types/narrow.rs index c656ac445771d..63a020faab8c1 100644 --- a/crates/ty_python_semantic/src/types/narrow.rs +++ b/crates/ty_python_semantic/src/types/narrow.rs @@ -1526,7 +1526,7 @@ impl<'db> PatternSuccessAnalyzer<'db> { // For example, `Child[int]` and `Base[T]` share a generic hierarchy, so a `Base` // pattern can reuse `int` from the subject. This does not infer `Child[int]` from // a `Base[int]` subject. - let shares_generic_hierarchy = original_subject_ty + if original_subject_ty .nominal_class(self.db) .is_some_and(|original_class| { unknown_pattern_class.is_subtype_of_class_literal( @@ -1536,8 +1536,8 @@ impl<'db> PatternSuccessAnalyzer<'db> { self.db, unknown_pattern_class.class_literal(self.db), ) - }); - if shares_generic_hierarchy { + }) + { // The pattern class's unknown specialization loses type arguments known // through the related subject type. Prefer the subject's member type when it // exists, but retain a member declared only by the pattern class.