From 8b24a47a27d0cdd50b92807d24f01ef54738cf96 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 30 Jul 2026 08:12:55 -0400 Subject: [PATCH] Infer tuple types for array literals against pair interface hints rbs 4.1 changed the block types of Array#to_h and Hash#to_h and the array-of-pairs argument of Hash.[] from tuples to the new Hash::_Pair interface (https://github.com/ruby/rbs/pull/2694). Steep only derives tuple hints from hint types that are tuple types themselves, so a pair literal in a to_h block is now inferred as an array and every call of the form to_h { |x| [key, value] } fails with Ruby::BlockBodyTypeMismatch: Cannot allow block body have type `::Array[(::Symbol | ::Integer)]` because declared as type `::Hash::_Pair[::Symbol, ::Integer]` Hash::_Pair is not a tuple type, but it declares the tuple it accepts through its #to_ary method. When the hint contains no tuple type directly, deconstruct interface and intersection hints with try_convert(type, :to_ary) and use the resulting tuple types as hints, so the array literal is inferred as a tuple and the existing tuple validation and fallback machinery applies unchanged. The intersection case arises when the block body hint combines a tuple with the interface, as in ([::Symbol, ::Numeric] & ::Hash::_Pair[::Symbol, ::Numeric]), where flattening finds no tuple because the intersection is opaque to flatten_union. A genuinely mismatched pair body still fails with Ruby::BlockBodyTypeMismatch, and now reports the precise tuple type of the body instead of an array type. --- lib/steep/type_construction.rb | 13 ++++ test/type_check_test.rb | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/lib/steep/type_construction.rb b/lib/steep/type_construction.rb index 5d6dee06c..592c18fd9 100644 --- a/lib/steep/type_construction.rb +++ b/lib/steep/type_construction.rb @@ -1761,6 +1761,19 @@ def synthesize(node, hint: nil, condition: false) else if hint tuples = select_flatten_types(hint) {|type| type.is_a?(AST::Types::Tuple) } #: Array[AST::Types::Tuple] + if tuples.empty? + # An interface hint like `Hash::_Pair[K, V]` (which rbs 4.1 uses for the blocks of `Array#to_h` + # and `Hash#to_h` and for the array-of-pairs argument of `Hash.[]`) is not a tuple type itself, + # but declares the tuple it accepts through its `#to_ary` method. + # Deconstruct such hints so the array literal can be inferred as a tuple. + candidates = select_flatten_types(hint) do |type| + type.is_a?(AST::Types::Name::Interface) || type.is_a?(AST::Types::Intersection) + end + tuples = candidates.filter_map do |type| + converted = try_convert(type, :to_ary) + converted if converted.is_a?(AST::Types::Tuple) + end #: Array[AST::Types::Tuple] + end unless tuples.empty? fallback_pair = nil #: Pair? tuples.each do |tuple| diff --git a/test/type_check_test.rb b/test/type_check_test.rb index 5d70a1d28..f0f2d5b1c 100644 --- a/test/type_check_test.rb +++ b/test/type_check_test.rb @@ -4580,4 +4580,133 @@ class OrAssignBox[T] YAML ) end + + def test_array_literal_with_pair_interface_hint + run_type_check_test( + signatures: { + "a.rbs" => <<~RBS + interface _KeyValue[K, V] + def to_ary: () -> [K, V] + end + + class PairMaker + def make: [K, V] () { (String) -> _KeyValue[K, V] } -> Hash[K, V] + end + RBS + }, + code: { + "a.rb" => <<~RUBY + maker = PairMaker.new + hash = maker.make {|string| [string, string.size] } + hash.each_key {|key| key.upcase } + hash.each_value {|value| value + 1 } + RUBY + }, + expectations: <<~YAML + --- + - file: a.rb + diagnostics: [] + YAML + ) + end + + def test_array_literal_with_pair_interface_intersection_hint + run_type_check_test( + signatures: { + "a.rbs" => <<~RBS + interface _KeyValue[K, V] + def to_ary: () -> [K, V] + end + + class PairMaker + def make_pair: () { (String) -> ([Symbol, Integer] & _KeyValue[Symbol, Integer]) } -> Hash[Symbol, Integer] + end + RBS + }, + code: { + "a.rb" => <<~RUBY + maker = PairMaker.new + maker.make_pair {|string| [string.to_sym, string.size] } + RUBY + }, + expectations: <<~YAML + --- + - file: a.rb + diagnostics: [] + YAML + ) + end + + def test_array_literal_with_pair_interface_hint_two_params_block + run_type_check_test( + signatures: { + "a.rbs" => <<~RBS + interface _KeyValue[K, V] + def to_ary: () -> [K, V] + end + + class PairMaker + def make_from_pairs: [K, V] () { (Symbol, Integer) -> _KeyValue[K, V] } -> Hash[K, V] + end + RBS + }, + code: { + "a.rb" => <<~RUBY + maker = PairMaker.new + hash = maker.make_from_pairs {|key, value| [key.to_s, value.to_f] } + hash.each_key {|key| key.upcase } + hash.each_value {|value| value.floor } + RUBY + }, + expectations: <<~YAML + --- + - file: a.rb + diagnostics: [] + YAML + ) + end + + def test_array_literal_with_pair_interface_hint_mismatch + run_type_check_test( + signatures: { + "a.rbs" => <<~RBS + interface _KeyValue[K, V] + def to_ary: () -> [K, V] + end + + class PairMaker + def make_exact: () { (String) -> _KeyValue[Symbol, Integer] } -> Hash[Symbol, Integer] + end + RBS + }, + code: { + "a.rb" => <<~RUBY + maker = PairMaker.new + maker.make_exact {|string| [string, 1] } + RUBY + }, + expectations: <<~YAML + --- + - file: a.rb + diagnostics: + - range: + start: + line: 2 + character: 17 + end: + line: 2 + character: 40 + severity: ERROR + message: |- + Cannot allow block body have type `[::String, ::Integer]` because declared as type `::_KeyValue[::Symbol, ::Integer]` + [::String, ::Integer] <: ::_KeyValue[::Symbol, ::Integer] + () -> [::String, ::Integer] <: () -> [::Symbol, ::Integer] + [::String, ::Integer] <: [::Symbol, ::Integer] + ::String <: ::Symbol + ::Object <: ::Symbol + ::BasicObject <: ::Symbol + code: Ruby::BlockBodyTypeMismatch + YAML + ) + end end