Skip to content

Fix UnreachableBranch on == against literal supertype (Symbol / Integer / String)#2223

Merged
soutaro merged 1 commit into
soutaro:masterfrom
rhiroe:fix-literal-equality-narrowing
May 14, 2026
Merged

Fix UnreachableBranch on == against literal supertype (Symbol / Integer / String)#2223
soutaro merged 1 commit into
soutaro:masterfrom
rhiroe:fix-literal-equality-narrowing

Conversation

@rhiroe

@rhiroe rhiroe commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a regression introduced by #2101 (shipped in 2.0.0) where comparing a value of type Symbol (or Integer / String) against a literal symbol/integer/string with == is incorrectly reported as Ruby::UnreachableBranch.

Closes #2222

Reproduction

a.rb:

class A
  def foo(symbol)
    if symbol == :qux
      puts "qux"
    else
      puts "other"
    end
  end
end

a.rbs:

class A
  def foo: (Symbol symbol) -> void
end
$ bundle exec steep check --severity-level=hint
a.rb:3:4: [hint] The branch is unreachable
│ Diagnostic ID: Ruby::UnreachableBranch

└     if symbol == :qux
      ~~

The same warning was emitted for Integer == 1, String == "x", and the postfix-if form.

Cause

#2101 added for_receiver: true to LogicTypeInterpreter#literal_var_type_case_select so that == against a literal could narrow union members like Result | :some_symbol properly: Result cannot equal :some_symbol, so it should flow only into the falsy branch.

The implementation, however, applied that rule to every non-literal type. Symbol, Integer, and String are non-literal class types but they include the corresponding literal values, so dropping them from the truthy bucket made the if branch look unreachable.

Fix

In for_receiver: true mode, narrow only when the literal type is a subtype of the receiver type:

literal_type = AST::Types::Literal.new(value: value_node.children[0])
if !for_receiver || subtyping?(sub_type: literal_type, super_type: type)
  true_types << literal_type
end
false_types << type

Behavior matrix:

Receiver type vs literal Truthy Falsy
Symbol == :qux :qux Symbol
Result == :some_symbol (unrelated class) (empty → unreachable) Result
(Result | :some_symbol) == :some_symbol :some_symbol Result
:foo == :qux (literal vs literal) (empty → unreachable) :foo

The first row is the regression being fixed. The remaining rows match #2101's intent and continue to pass smoke/narrowing-else.

soutaro#2101 made the `==` (`for_receiver: true`) path of
`literal_var_type_case_select` place every non-literal type into the
falsy bucket only. That works for unrelated class types like the
`Result` member of `Result | :some_symbol`, but it also drops literal
*supertypes* such as `Symbol`, `Integer`, and `String` from the truthy
bucket. As a result, code like

    def foo(symbol) # symbol: Symbol
      if symbol == :qux
        ...
      end
    end

was reported as `Ruby::UnreachableBranch` even though `Symbol` obviously
includes `:qux`.

Narrow only when the literal type is a subtype of the receiver type so
that supertypes of the literal still flow into the truthy branch
(`Symbol == :qux` can be true), while unrelated class types remain
falsy-only (`Result == :some_symbol` cannot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rhiroe rhiroe force-pushed the fix-literal-equality-narrowing branch from 7a8a79f to d9b5604 Compare May 7, 2026 08:15
@soutaro soutaro added this to the Steep 2.1 milestone May 14, 2026

@soutaro soutaro left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏

@soutaro soutaro merged commit c0d6a36 into soutaro:master May 14, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UnreachableBranch reported for == against a literal when the receiver is a literal supertype (Symbol, Integer, String)

2 participants