Skip to content

Parameter type includes values assigned via Hash#[]= on local variable #362

Open

Description

@sinsoku

Steps to reproduce

Analyze the following file with TypeProf.

def foo(options)
  options[:name] = "str"
  nil
end

foo(Hash.new)

Expected behavior

class Object
  def foo: (Hash[untyped, untyped]) -> nil
end

Parameter types should only reflect the types passed from call sites, not values assigned inside the method.

Actual behavior

$ bin/typeprof reproduce.rb
# TypeProf 0.31.1

# reproduce.rb
class Object
  def foo: (Hash[:name, String]) -> nil
end

System configuration

Ruby version: ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin25]
TypeProf version: 753de9b

Real-world use cases

I noticed this issue when analyzing Action Pack with TypeProf.

The minimum steps required to actually analyze Action Pack are as follows:

$ bin/typeprof tmp/rails/actionpack/test/controller/routing_test.rb \
  tmp/rails/actionpack/lib/action_dispatch/routing/route_set.rb \
  tmp/rails/actionpack/lib/action_dispatch/journey/formatter.rb > ap.rbs

$ cat ap.rbs | grep url_for | wc -c
   13936

$ cat ap.rbs | grep url_for
      def url_for: (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], (Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], String] | Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], untyped] | String)?] | Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | :path | :path_params | :person | :port | :protocol | :script_name | :subdomain | :tld_length | :trailing_slash | :use_route | :user | Range[Integer], String] | Hash[:_recall | :action | :anchor | :controller | :domain | :foo | :format | :host | :id | :name | :only_path | :original_script_name | :params | :password | ...(omitted

The parameter type contains a recursive structure such as Hash[:_recall | ..., Hash[:_recall | ..., Hash[:_recall, ...].
I have identified the cause of this as Hash#[]=.

Activity

  1. mame commented on Feb 9, 2026

    @mame
    Member

    Hmm. I am curious what pattern of ActionPack's code is causing the problem. Give me a time to look into it.

    If we fix it, we'll probably need to handle Array#[]= similarly.

  2. sinsoku commented on Feb 15, 2026

    @sinsoku
    CollaboratorAuthor

    @mame
    I stripped down the Action Pack code to create a minimal reproduction of this issue:

    class C
      def foo(options)
        _ = bar(options)
        options = options.merge({})
        result = bar(options)
        options[:params] = result[:data]
    
        nil
      end
    
      def bar(options)
        options[:controller] = options[:controller]
        params = options.delete_if { }
    
        { data: params }
      end
    end
    
    C.new.foo({ controller: "people" })

    Running TypeProf on this produces the following RBS:

    # TypeProf 0.31.1
    
    # issue.rb
    class C
      def foo: ({ controller: Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | String] | Hash[:controller, Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | Hash[:controller, untyped] | String] | String }) -> nil
      def bar: (Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | String] | { controller: Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | String] | Hash[:controller, Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | Hash[:controller, untyped] | String] | String }) -> { data: Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | String] | Hash[:controller, Hash[:controller | :params, Hash[:controller | :params, untyped] | Hash[:controller, untyped] | String] | Hash[:controller, untyped] | String] }
    end

    The deeply nested Hash types are caused by the parameter type pollution from Hash#[]=.
    With the fix in #363, the output becomes:

    # TypeProf 0.31.1
    
    # issue.rb
    class C
      def foo: (Hash[:controller, String]) -> nil
      def bar: (Hash[:controller, String]) -> Hash[:data, Hash[:controller, String]]
    end
  3. sinsoku commented on Feb 15, 2026

    @sinsoku
    CollaboratorAuthor

    A patch has been implemented that also supports Array#[]=.
    sinsoku@d7b9774

    I plan to make a separate PR after merging #363.

  4. mame commented on Feb 25, 2026

    @mame
    Member

    Sorry for the late response. I am still considering this and want to share my current thoughts.

    The current behavior is somewhat intentional. For example, in a pattern like:

    def foo(**opt)
      opt[:key1] ||= 42
      opt[:key2] ||= "default"
    end
    

    I want to make TypeProf infer def foo: (key1: Integer, key2: String) -> ....
    This relies on []= feeding type information back into the parameter's type.

    In the minimal reproduction you provided, options[:params] = result[:data] is essentially options[:params] = options, so the recursive inference result is somewhat expected.

    That said, I understand that overly verbose inference results are a real problem in practice, especially with Rails code where hash values get passed around and mutated across many methods. I'm still thinking about how to balance the intentional backflow behavior with preventing type explosion in complex codebases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions