Skip to content

Fix is_a? with a union alias resolving to common ancestor#17052

Open
stakach wants to merge 5 commits into
crystal-lang:masterfrom
stakach:fix-is-a-union-alias
Open

Fix is_a? with a union alias resolving to common ancestor#17052
stakach wants to merge 5 commits into
crystal-lang:masterfrom
stakach:fix-is-a-union-alias

Conversation

@stakach

@stakach stakach commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Resolves #9665.

Issue

A union used inline keeps its members, but the same union behind an alias was resolved to the members' common ancestor, so an unrelated sibling type matched. This affected every place a union flows through type resolution:

class Foo; end
class Bar < Foo; end
class Baz < Foo; end
class Bang < Foo; end

alias BarBaz = Bar | Baz

Bang.new.is_a?(BarBaz)    # => true  (wrong)
Bang.new.is_a?(Bar | Baz) # => false (correct)

case Bang.new
when BarBaz then ...   # matched (wrong)
end

def foo(x : BarBaz); end
foo(Bang.new)             # accepted (wrong)

begin
  raise BangError.new
rescue ex : BarBaz        # caught (wrong)
end

Fix

Aliases of unions now preserve their members, matching the inline union form, while still generalizing to the common ancestor in value contexts (pp BarBaz # => Foo is unchanged):

  • is_a? and case ... when / case ... in against a union alias no longer generalize. A when Foo | Bar now matches like when Foo, Bar; unions of constants (enum members, numeric constants) keep their existing value comparison.
  • Method restrictions (def foo(x : BarBaz)) restrict against the union's members.
  • rescue ex : BarBaz catches the union's members rather than their common ancestor.

Covers A | B, Union(A, B), and nested-alias forms.

stakach added 2 commits June 8, 2026 19:25
…lang#9665)

A union used inline inside `is_a?` keeps its members, but the same union
behind an alias was resolved to the members' common ancestor, so unrelated
sibling types matched. Aliases of unions now keep their members inside
`is_a?`, matching inline union behaviour, while still generalizing in value
contexts.
A `when Foo | Bar` was compared with `===` against the union value, which
generalizes to the members' common ancestor, so unrelated sibling types
matched. It now lowers to `is_a?` against the union (like `when Foo, Bar`),
while unions of constants (enum members, numeric constants) keep their
original value comparison.
@stakach stakach force-pushed the fix-is-a-union-alias branch from e5279a0 to e54cb92 Compare June 8, 2026 11:10
@ysbaddaden

Copy link
Copy Markdown
Collaborator

Did you consider #9665 (comment)

A `def foo(x : BarBaz)` where `alias BarBaz = Bar | Baz` resolved the alias
to the members' common ancestor, so it accepted unrelated sibling types. It
now restricts against the union's members, matching the inline
`def foo(x : Bar | Baz)`.
@stakach

stakach commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Did you consider #9665 (comment)

Yes, however the AST-node-restriction unification would be a much bigger change.

stakach added 2 commits June 9, 2026 00:51
A `rescue ex : BarBaz` where `alias BarBaz = Bar | Baz` resolved the alias
to the members' common ancestor, so it caught unrelated sibling exceptions.
It now catches the union's members, matching the inline
`rescue ex : Bar | Baz`.
…rystal-lang#9665)

The 1.0.0 bootstrap compiler used in CI doesn't narrow a variable assigned
inside a compound `&&`/`unless` condition, so the union and rescue handling
failed to compile (`undefined method 'first' for Nil`). Lift those
assignments out of the conditions; no behaviour change.
@stakach

stakach commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

This is ready for review now :)

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.

is_a? with unions has unexpected behaviour

2 participants