From ab5cbaf793abf7b9b5a9db461d33fe2200e74c85 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Wed, 15 Jul 2026 13:59:56 -0700 Subject: [PATCH 1/4] Fix generic type validation for T::Props and composite members `GenericTypeRegistry::GenericType` overrides `valid?` to check the underlying constant rather than the generic clone `raw_type`, but `recursively_valid?` is inherited from `T::Types::Simple` and checks `raw_type` directly. Composite types (`T.all`, `T::Hash`, ...) and `T::Props`/`T::Struct` setters validate via `recursively_valid?`, so instances of the underlying type are wrongly rejected there. Delegate `recursively_valid?` to `valid?` so validation is consistent. Fixes #2130. --- lib/tapioca/runtime/generic_type_registry.rb | 9 +++++++++ spec/tapioca/runtime/generic_type_registry_spec.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/tapioca/runtime/generic_type_registry.rb b/lib/tapioca/runtime/generic_type_registry.rb index 9cdb5d5ee..379885512 100644 --- a/lib/tapioca/runtime/generic_type_registry.rb +++ b/lib/tapioca/runtime/generic_type_registry.rb @@ -38,6 +38,15 @@ def initialize(raw_type, underlying_type) def valid?(obj) obj.is_a?(@underlying_type) end + + # `T::Types::Base#recursively_valid?` checks the clone `raw_type` instead of + # routing through `valid?`, so delegate to keep composite (`T.all`, `T::Hash`) + # and `T::Props` member validation consistent with the `valid?` override above. + # @override + #: (untyped obj) -> bool + def recursively_valid?(obj) + valid?(obj) + end end class << self diff --git a/spec/tapioca/runtime/generic_type_registry_spec.rb b/spec/tapioca/runtime/generic_type_registry_spec.rb index 3f2b137f0..4271d8b53 100644 --- a/spec/tapioca/runtime/generic_type_registry_spec.rb +++ b/spec/tapioca/runtime/generic_type_registry_spec.rb @@ -62,6 +62,19 @@ class GenericTypeRegistrySpec < Minitest::Spec T.let(SampleGenericInterfaceImplementation.new, SampleGenericInterface[Object]) end + it "recognizes underlying-type instances via recursively_valid? in composite/T::Props members (issue #2130)" do + struct_class = T.let( + Class.new(T::Struct) do + const :members, T::Hash[Symbol, SampleGenericInterface[Object]] + end, + T.untyped, + ) + + instance = struct_class.new(members: { key: SampleGenericInterfaceImplementation.new }) + + assert_instance_of(SampleGenericInterfaceImplementation, instance.members.fetch(:key)) + end + it "does not reuse generic instances for redefined constants with the same name" do first_constant, first_generic_type = register_reloadable_generic From b7dc82dae2b8d9517c27c1e81fa1b549d4c0d34c Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 16 Jul 2026 09:31:59 -0700 Subject: [PATCH 2/4] Subclass T::Types::Base so bare generic T::Props members validate GenericType previously subclassed T::Types::Simple. sorbet-runtime's SetterFactory has an is_a?(T::Types::Simple) fast path that reads raw_type directly and bypasses valid?/recursively_valid?, so a bare generic prop member (e.g. const :x, I[Object]) rejected instances of the underlying constant. Subclassing T::Types::Base routes all validation through the valid? override. --- lib/tapioca/runtime/generic_type_registry.rb | 37 +++++++++++++++---- .../runtime/generic_type_registry_spec.rb | 16 ++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/tapioca/runtime/generic_type_registry.rb b/lib/tapioca/runtime/generic_type_registry.rb index 379885512..cdcdc782e 100644 --- a/lib/tapioca/runtime/generic_type_registry.rb +++ b/lib/tapioca/runtime/generic_type_registry.rb @@ -25,11 +25,21 @@ module GenericTypeRegistry @type_variables = {}.compare_by_identity #: Hash[Module[top], Array[TypeVariableModule]] - class GenericType < T::Types::Simple + # Subclasses `T::Types::Base` rather than `T::Types::Simple` on purpose. Our + # `raw_type` is the generic *clone*, not the underlying constant, so the + # `obj.is_a?(raw_type)` checks that `Simple` and its `is_a?(Simple)` fast paths + # (e.g. `T::Props::Private::SetterFactory`) perform against `raw_type` would + # reject instances of the underlying constant. `Base` routes all validation + # through our `valid?` override instead. + class GenericType < T::Types::Base + #: Module[top] + attr_reader :raw_type + #: (Module[top] raw_type, Module[top] underlying_type) -> void def initialize(raw_type, underlying_type) - super(raw_type) + super() + @raw_type = raw_type @underlying_type = underlying_type #: Module[top] end @@ -39,13 +49,24 @@ def valid?(obj) obj.is_a?(@underlying_type) end - # `T::Types::Base#recursively_valid?` checks the clone `raw_type` instead of - # routing through `valid?`, so delegate to keep composite (`T.all`, `T::Hash`) - # and `T::Props` member validation consistent with the `valid?` override above. # @override - #: (untyped obj) -> bool - def recursively_valid?(obj) - valid?(obj) + #: -> String + def name + T.must(@raw_type.name) + end + + # @override + #: -> nil + def build_type + nil + end + + # We are not implementing a runtime type checker, so it is enough for + # covariance/contravariance checks to pass, matching the always-true `<=` + # we define on the generic clone in `create_generic_type`. + #: (T::Types::Base type) -> bool + private def subtype_of_single?(type) + true end end diff --git a/spec/tapioca/runtime/generic_type_registry_spec.rb b/spec/tapioca/runtime/generic_type_registry_spec.rb index 4271d8b53..57c19d5d8 100644 --- a/spec/tapioca/runtime/generic_type_registry_spec.rb +++ b/spec/tapioca/runtime/generic_type_registry_spec.rb @@ -75,6 +75,22 @@ class GenericTypeRegistrySpec < Minitest::Spec assert_instance_of(SampleGenericInterfaceImplementation, instance.members.fetch(:key)) end + it "recognizes underlying-type instances in a bare T::Props member (issue #2130)" do + # `T::Props::Private::SetterFactory` takes a `T::Types::Simple` fast path + # that reads `raw_type` directly and bypasses `valid?`/`recursively_valid?`. + # A bare generic member (not nested in a composite) exercises that path. + struct_class = T.let( + Class.new(T::Struct) do + const :member, SampleGenericInterface[Object] + end, + T.untyped, + ) + + instance = struct_class.new(member: SampleGenericInterfaceImplementation.new) + + assert_instance_of(SampleGenericInterfaceImplementation, instance.member) + end + it "does not reuse generic instances for redefined constants with the same name" do first_constant, first_generic_type = register_reloadable_generic From 4f7193c843beb618b7da6fd8d81754ae01a12282 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Thu, 16 Jul 2026 14:37:57 -0700 Subject: [PATCH 3/4] Trim comments to match repo style --- lib/tapioca/runtime/generic_type_registry.rb | 14 +++++--------- spec/tapioca/runtime/generic_type_registry_spec.rb | 6 ++---- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/lib/tapioca/runtime/generic_type_registry.rb b/lib/tapioca/runtime/generic_type_registry.rb index cdcdc782e..1a63cbbd9 100644 --- a/lib/tapioca/runtime/generic_type_registry.rb +++ b/lib/tapioca/runtime/generic_type_registry.rb @@ -25,12 +25,10 @@ module GenericTypeRegistry @type_variables = {}.compare_by_identity #: Hash[Module[top], Array[TypeVariableModule]] - # Subclasses `T::Types::Base` rather than `T::Types::Simple` on purpose. Our - # `raw_type` is the generic *clone*, not the underlying constant, so the - # `obj.is_a?(raw_type)` checks that `Simple` and its `is_a?(Simple)` fast paths - # (e.g. `T::Props::Private::SetterFactory`) perform against `raw_type` would - # reject instances of the underlying constant. `Base` routes all validation - # through our `valid?` override instead. + # Subclasses `T::Types::Base`, not `T::Types::Simple`: `Simple` fast paths + # (e.g. `T::Props::Private::SetterFactory`) check `raw_type` directly, but our + # `raw_type` is the clone, so they reject instances of the underlying constant. + # `Base` routes all validation through the `valid?` override below. class GenericType < T::Types::Base #: Module[top] attr_reader :raw_type @@ -61,9 +59,7 @@ def build_type nil end - # We are not implementing a runtime type checker, so it is enough for - # covariance/contravariance checks to pass, matching the always-true `<=` - # we define on the generic clone in `create_generic_type`. + # Matches the always-true `<=` we define on the clone in `create_generic_type`. #: (T::Types::Base type) -> bool private def subtype_of_single?(type) true diff --git a/spec/tapioca/runtime/generic_type_registry_spec.rb b/spec/tapioca/runtime/generic_type_registry_spec.rb index 57c19d5d8..49996602d 100644 --- a/spec/tapioca/runtime/generic_type_registry_spec.rb +++ b/spec/tapioca/runtime/generic_type_registry_spec.rb @@ -62,7 +62,7 @@ class GenericTypeRegistrySpec < Minitest::Spec T.let(SampleGenericInterfaceImplementation.new, SampleGenericInterface[Object]) end - it "recognizes underlying-type instances via recursively_valid? in composite/T::Props members (issue #2130)" do + it "recognizes underlying-type instances in a composite T::Props member (issue #2130)" do struct_class = T.let( Class.new(T::Struct) do const :members, T::Hash[Symbol, SampleGenericInterface[Object]] @@ -76,9 +76,7 @@ class GenericTypeRegistrySpec < Minitest::Spec end it "recognizes underlying-type instances in a bare T::Props member (issue #2130)" do - # `T::Props::Private::SetterFactory` takes a `T::Types::Simple` fast path - # that reads `raw_type` directly and bypasses `valid?`/`recursively_valid?`. - # A bare generic member (not nested in a composite) exercises that path. + # A bare (non-composite) member exercises the `T::Types::Simple` setter fast path. struct_class = T.let( Class.new(T::Struct) do const :member, SampleGenericInterface[Object] From ce867cc2cfe7fb928c7e4d1ef4be5eaaa13e9ae0 Mon Sep 17 00:00:00 2001 From: Douglas Eichelberger Date: Mon, 20 Jul 2026 17:02:14 -0700 Subject: [PATCH 4/4] Tighten GenericType class comment --- lib/tapioca/runtime/generic_type_registry.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/tapioca/runtime/generic_type_registry.rb b/lib/tapioca/runtime/generic_type_registry.rb index 1a63cbbd9..92e1c9939 100644 --- a/lib/tapioca/runtime/generic_type_registry.rb +++ b/lib/tapioca/runtime/generic_type_registry.rb @@ -25,10 +25,9 @@ module GenericTypeRegistry @type_variables = {}.compare_by_identity #: Hash[Module[top], Array[TypeVariableModule]] - # Subclasses `T::Types::Base`, not `T::Types::Simple`: `Simple` fast paths - # (e.g. `T::Props::Private::SetterFactory`) check `raw_type` directly, but our - # `raw_type` is the clone, so they reject instances of the underlying constant. - # `Base` routes all validation through the `valid?` override below. + # Subclasses `T::Types::Base` rather than `T::Types::Simple` so that all + # validation goes through the `valid?` override below. `Simple` fast paths + # check `raw_type` directly, which for us is the clone, not the underlying type. class GenericType < T::Types::Base #: Module[top] attr_reader :raw_type