Skip to content

Fix generic type validation for T::Props and composite members#2673

Open
dduugg wants to merge 3 commits into
Shopify:mainfrom
dduugg:fix-2130-generic-recursively-valid
Open

Fix generic type validation for T::Props and composite members#2673
dduugg wants to merge 3 commits into
Shopify:mainfrom
dduugg:fix-2130-generic-recursively-valid

Conversation

@dduugg

@dduugg dduugg commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Motivation

Fixes #2130.

When a constant is typed with a Tapioca generic type (e.g. a generic interface Foo[Bar]) as a T::Props/T::Struct member, instantiating or setting that member raises a spurious TypeError, even though the value is an instance of the underlying constant. This happens whether the member is typed with the generic directly (const :x, Foo[Bar]) or nested inside a composite type (T::Hash[..., Foo[Bar]], T.all(Foo[Bar], ...)), and it blocks bin/tapioca dsl from loading applications that use generic types in prop/struct members.

Implementation

GenericTypeRegistry::GenericType overrides valid? to validate against the underlying constant (@underlying_type) instead of the generic clone it wraps as raw_type. Previously it subclassed T::Types::Simple, but Simple validates with obj.is_a?(raw_type) and sorbet-runtime has fast paths keyed on is_a?(T::Types::Simple) that read raw_type directly and bypass valid?/recursively_valid? entirely:

  • T::Props::Private::SetterFactory uses simple_non_nil_proc/simple_nilable_proc for Simple types, checking val.is_a?(raw_type) inline. This is what a bare const :x, Foo[Bar] (or T.nilable(Foo[Bar])) member hits.
  • T.nilable union construction takes a SimplePairUnion fast path for Simple members.

Because raw_type is the clone rather than the underlying constant, every one of these rejects a valid instance. Subclassing T::Types::Base instead removes all of those fast paths, so all validation routes through the valid? override. Base is more minimal than Simple, so the pieces it does not provide are added: name, build_type, and subtype_of_single? (which returns true, matching the always-true <= we already define on the clone in create_generic_type).

This complements #2657, which fixed the T.let/T.cast coercion path but did not cover the setter/composite validation paths.

Tests

Added tests to spec/tapioca/runtime/generic_type_registry_spec.rb covering both a bare T::Struct member (const :member, SampleGenericInterface[Object]) and a composite member (T::Hash[Symbol, SampleGenericInterface[Object]]), each constructed with an implementation instance. Both fail with a TypeError before this change and pass after.

`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 Shopify#2130.
@dduugg
dduugg requested a review from a team as a code owner July 15, 2026 21:14

@KaanOzkan KaanOzkan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My agent found a case where sorbet-runtime takes a different code path for T::Types::Simple and bypasses valid? or recursively_valid? checks.

require "tapioca/internal"

module I
  extend T::Generic
  interface!
  E = type_member
end

class C
  include I
end

class S < T::Struct
  const :x, I[Object]
end

S.new(x: C.new)

results in TypeError: Parameter 'x': Can't set S.x to #<C:0x0000000127c91288> (instance of C) - need a I[::Object]

Maybe we shouldn't inherit from T::Types::Simple 🤔 , wdyt?

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.
@dduugg

dduugg commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Good catch, you're right, and I've taken your suggestion.

The bare T::Struct member hits SetterFactory's is_a?(T::Types::Simple) fast path, which reads raw_type directly and skips both valid? and recursively_valid?. Since our raw_type is the generic clone rather than the underlying constant, val.is_a?(raw_type) is false for an instance of the constant, so the overrides never got a chance to run. (T.nilable(...) union construction has the same Simple-keyed fast path.)

So instead of overriding recursively_valid?, GenericType now subclasses T::Types::Base. That removes the is_a?(Simple) fast paths entirely and routes every validation path through the valid? override. I filled in the pieces Simple had provided (name, build_type, and subtype_of_single?, which just returns true, matching the always-true <= we define on the clone) and dropped the now-unnecessary recursively_valid? override.

Added a spec for your exact repro (bare generic member) alongside the composite one. bin/typecheck and the generic specs are green.

# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this comment can be improved by changing the language and making it more concise, or be removed completely.

#: (T::Types::Base type) -> bool
private def subtype_of_single?(type)
true
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I guess it's okay to allow everything while running tapioca dsl? There's a similar comment on create_generic_type below.

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.

Tapioca DSL crashes when using T::Struct containing generic members

2 participants