Skip to content

Symmetric common_descendent for two generic class types (#10831)#16999

Merged
straight-shoota merged 2 commits into
crystal-lang:masterfrom
stakach:fix-isa-chain-generic-symmetric
Jun 3, 2026
Merged

Symmetric common_descendent for two generic class types (#10831)#16999
straight-shoota merged 2 commits into
crystal-lang:masterfrom
stakach:fix-isa-chain-generic-symmetric

Conversation

@stakach

@stakach stakach commented May 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #10831.

The bug

class A; end
class B(T) < A; end
class C(T) < B(T); end

x = C(Int32).new.as(A)
x.is_a?(B) && x.is_a?(C)   # => false   (should be true)

After the first is_a?(B) narrows x to B(T), the chained is_a?(C) is supposed to narrow further to C(T) — every runtime C is also a B. Instead it evaluates to false even though the runtime value really is a C.

Root cause

Type::common_descendent(GenericClassType, GenericClassType) only walked the relationship in one direction:

def self.common_descendent(type1 : GenericClassType, type2 : GenericClassType)
  return type1 if type1 == type2

  common_descendent_instance_and_generic(type1, type2)
end

common_descendent_instance_and_generic(B, C) asks "is there a path from B's parents down to C?" — and there isn't (B is the parent, C is the child). The intersection collapses, the filter narrows to nothing, and is_a?(C) returns false.

Fix

Try both directions, so the relationship between two generic class types flows whichever way the chain happens to be written:

common_descendent_instance_and_generic(type1, type2) ||
  common_descendent_instance_and_generic(type2, type1)

The symmetric form mirrors the pattern already used for VirtualType, VirtualType (line 170) and for the class C(T) < B(T) family of relationships specifically.

Tests

Note

This was originally part of #16982 along with #11134's virtual metaclass dispatch fix. Per review feedback, they're being split into separate PRs — they're related symptoms but fix different code paths and warrant independent review.

…ng#10831)

`x.is_a?(B) && x.is_a?(C)` returns `false` for a runtime value that is
actually a `C` when `class C(T) < B(T)`. After the first filter, `x` is
narrowed to `B(T)`. The chained `is_a?(C)` then asks
`common_descendent(B, C)`, which walked the relationship in one
direction only (`common_descendent_instance_and_generic(B, C)`) and
couldn't reach `C` from `B`. The intersection collapsed and the filter
evaluated to `false`.

Try both directions so the relationship between generic class types
flows whichever way the chain happens to be written.
Comment thread spec/compiler/semantic/is_a_spec.cr Outdated
Co-authored-by: Johannes Müller <straightshoota@gmail.com>
@ysbaddaden ysbaddaden added this to the 1.21.0 milestone Jun 2, 2026
@straight-shoota straight-shoota merged commit 7fdca33 into crystal-lang:master Jun 3, 2026
43 of 45 checks passed
@straight-shoota straight-shoota added kind:bug A bug in the code. Does not apply to documentation, specs, etc. topic:compiler:semantic labels Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind:bug A bug in the code. Does not apply to documentation, specs, etc. topic:compiler:semantic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

is_a?(T) can break further type filtering if T is an uninstantiated generic

3 participants