Skip to content

Same-class generic resolution binds to the first union member instead of the actual member #1272

Description

@apiology

Claude: drafted while investigating an unrelated PR; posting on the user's behalf via their GitHub credentials.

Summary

When a value's declared type is a union of two instantiations of the same @generic class (e.g. Box<Integer>, Box<String>), calling a method whose return type is generic<T> always resolves T against whichever instantiation is listed first in the union - regardless of which one the value would actually be at runtime, and regardless of any argument that might otherwise disambiguate it. Swapping the declared order of the union members changes the inferred type, which shouldn't be possible for a real union.

Reproduction

# @generic T
class Box
  # @return [generic<T>]
  def get; end
end

class Repro
  # @param b [Box<Integer>, Box<String>]
  # @return [void]
  def process(b)
    # @type [Integer]
    x = b.get
  end
end

solargraph typecheck --level strong on this: clean, b.get infers Integer.

Now swap the union's declared order, changing nothing else:

  # @param b [Box<String>, Box<Integer>]
  # @return [void]
  def process(b)
    # @type [Integer]
    x = b.get
  end
Declared type Integer does not match inferred type String for variable x

b.get now infers String - the only thing that changed was which member is listed first.

Root cause

Chain::Call#inferred_pins computes self_type once for the whole call and uses it to resolve the class's generic parameters:

self_type = if head?
              name_pin.context
            else
              name_pin.binder   # the *whole* union/intersection type
            end
...
type = with_params(new_return_type.self_to_type(self_type), self_type).qualify(api_map, *p.gates)

self_type here is name_pin.binder - the complete union type (Box<Integer>, Box<String>), not a specific member. Whatever generic-resolution logic binds Box's T against that combined self_type ends up matching structurally against the first member it finds, so generic<T> always resolves to the first union member's argument, independent of which member is actually relevant to the call.

Scope

This reproduces with a plain union (,), no intersection types (&) or any other new syntax involved - it's independent of castwide/solargraph#1231 (which adds intersection types) and #1266 (structural RBS interface checks). It also affects intersection-typed receivers the same way, since intersections and unions both flatten to the same per-member dispatch code in Chain::Call, but a plain union is sufficient to reproduce it on its own.

Environment

Reproduced on castwide/solargraph@8fda63384 (current master).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions