Skip to content

Support typed Data.define members via inline RBS comments#866

Draft
julianojulio wants to merge 1 commit into
masterfrom
ae-task-21-typed-data-define-members-via-inline-rbs
Draft

Support typed Data.define members via inline RBS comments#866
julianojulio wants to merge 1 commit into
masterfrom
ae-task-21-typed-data-define-members-via-inline-rbs

Conversation

@julianojulio

@julianojulio julianojulio commented Mar 24, 2026

Copy link
Copy Markdown

Summary

Add support for typing Data.define members using inline RBS comments on the symbol arguments. This solves the problem that Data.define members are always T.untyped with zero runtime cost — the type annotations live in comments that only the static analyzer reads.

Point = Data.define(
  :x, #: Integer
  :y, #: String
)

point = Point.new(x: 1, y: "hello")
T.assert_type!(point.x, Integer)    # typed!
T.assert_type!(point.y, String)     # typed!
Point.new(x: "bad", y: "hello")     # error: Expected `Integer`
Point.new(x: 1)                     # error: Missing required keyword argument `y`

Based on the typed Data.define approach originally designed by @cbothner in sorbet/sorbet#8079. This PR adds the RBS inline syntax (zero runtime cost) and incorporates the conservative bare-super check from @jez's review feedback on that PR.

Addresses sorbet#10055

Design decisions (informed by #8079 review)

Following @jez's principle that "the default assumption should be 'Sorbet doesn't do anything' and only if a certain set of very precise constraints are met should Sorbet do something":

  • Bare super required: Typed readers are only created when the initialize body is exactly bare super (desugars to send(untypedSuper, [ZSuperArgs])). When the user transforms values (e.g., super(x: x.to_i)), readers conservatively fall back to T.untyped. This is the canCreateTypedAccessors() check ported from Rewrite Data to create typed attribute readers based on initialize sig sorbet/sorbet#8079.

  • self.[] left untyped: Sorbet cannot overload a method to accept both positional and keyword arguments, so only new/initialize gets typed.

  • Explicit initialize takes precedence: If the user provides both inline #: types AND a def initialize with a sig in the block, the explicit initialize wins and the inline types are ignored (see "Interaction with Sorbet-style annotations" below).

  • No partial typing surprises: Un-annotated members get T.untyped in the synthesized sig. This enables gradually annotating members while preserving old behavior for unannotated ones.

Interaction with Sorbet-style annotations

The inline #: syntax composes cleanly with all existing annotation styles. The Data.cc type extraction operates on the shared desugared AST, so it works identically regardless of whether the sig came from inline RBS types, an RBS method sig, or a traditional Sorbet sig { } block.

Three ways to type Data.define members (all produce equivalent type checking when the initialize body is bare super):

# 1. Inline RBS types on arguments (zero runtime cost — this PR)
TypedPoint = Data.define(
  :x, #: Integer
  :y, #: String
)

# 2. RBS method sig + explicit initialize (zero runtime cost)
TypedPoint = Data.define(:x, :y) do
  #: (x: Integer, y: String) -> void
  def initialize(x:, y:) = super
end

# 3. Traditional Sorbet sig + explicit initialize (small runtime cost)
TypedPoint = Data.define(:x, :y) do
  extend T::Sig
  sig { params(x: Integer, y: String).void }
  def initialize(x:, y:) = super
end

Precedence when styles are mixed: If inline #: types are present AND the block contains an explicit def initialize (with either a Sorbet or RBS sig), the explicit initialize takes precedence and the inline types are ignored. This avoids conflicting type information:

# The Sorbet sig's Numeric takes precedence over inline #: Integer
BothStyles = Data.define(
  :x, #: Integer        # ← ignored
  :y, #: String         # ← ignored
) do
  extend T::Sig
  sig { params(x: Numeric, y: String).void }
  def initialize(x:, y:) = super
end
T.assert_type!(BothStyles.new(x: 1, y: "hi").x, Numeric)  # Numeric, not Integer

Non-bare-super fallback: When the initialize body does anything beyond bare super (e.g., coercing values), readers fall back to T.untyped regardless of annotation style. The sig still constrains the constructor:

CoercedData = Data.define(:amount, :currency) do
  extend T::Sig
  sig { params(amount: Numeric, currency: String).void }
  def initialize(amount:, currency:)
    super(amount: amount.to_i, currency: currency)  # not bare super
  end
end
T.reveal_type(CoercedData.new(amount: 10, currency: "CAD").amount)  # T.untyped
CoercedData.new(amount: :bad, currency: "CAD")  # error: Expected Numeric

Approach

Prism RBS pipeline (Parse → CommentsAssociatorPrismSigsRewriterPrism):

  • rbs/prism/CommentsAssociatorPrism.cc: Detects root Data.define(...) / ::Data.define(...) calls and captures #: comments on each symbol argument. Stored in a new dataDefineMembersForNode map in CommentMapPrism keyed by non-owning Prism call-node pointers.

  • rbs/prism/SigsRewriterPrism.cc: When a Data.define has typed members, synthesizes Prism C AST nodes equivalent to sig { params(x: Integer, y: String).void } + def initialize(x:, y:) = super into the Data.define block body. This creates pm_def_node_t, pm_parameters_node_t, required keyword parameter nodes, and a forwarding-super node. The bare-super body is critical — it's what canCreateTypedAccessors() in Data.cc checks to propagate types.

Data rewriter (rewriter/Data.cc):

  • getInitialize(): Finds a def initialize in the block body and its preceding sig.
  • canCreateTypedAccessors(): Checks the initialize body is exactly bare super.
  • getMemberType(): Extracts per-member types from the sig's params() call.
  • When all conditions are met, generates sig { returns(Type) } before each reader method instead of the default T.untyped.

Test coverage

test/testdata/rbs/data_define_members.rb:

  • Basic typed members, wrong type errors, missing kwarg errors, extra kwarg errors
  • Complex types: String? (nilable), Integer | String (union), Array[String], Hash[Symbol, Integer], bool
  • Partially typed (some members T.untyped), T.assert_type! validation
  • Blocks with additional methods, fully-qualified ::Data.define
  • Single member, many members (5+), nested in modules
  • Malformed type comment — graceful degradation to T.untyped

test/testdata/rbs/data_define_members_edge_cases.rb:

  • Untyped Data.define (no comments) — unchanged behavior
  • Explicit initialize with defaults takes precedence over inline types
  • Block with methods + inline types (no explicit initialize) — types propagate
  • Block with methods only (no types) — members stay T.untyped
  • Empty block with typed members
  • Scoped Foo::Data.define — correctly not rewritten
  • Sorbet sig interaction: traditional sig { } + bare-super initialize
  • Mixed styles: inline #: types + explicit Sorbet sig → explicit wins
  • Mixed styles: inline #: types + explicit RBS sig → explicit wins
  • Non-bare-super with sig: readers untyped, constructor still constrained

Limitations

Single-line syntax: The inline #: syntax requires spreading Data.define across multiple lines — each member must be on its own line to have a trailing #: comment. Single-line Data.define(:x, :y) cannot have per-member inline comments (Ruby comment syntax extends to end of line). This is inherent to using trailing comments and not something we can change.

Multiline types: Multiline RBS continuation (#|) is not supported for inline member type annotations. Each member's type must fit on a single line. For complex types that don't fit, users can fall back to the explicit initialize pattern with a full method signature. This limitation is acceptable because Data.define member types are typically simple (class names, nilable wrappers, unions).

self.[] untyped: The self.[] constructor method remains untyped even when members are typed. Sorbet cannot overload a method to accept both positional and keyword arguments, so only the keyword-based new/initialize path gets type constraints. This matches the behavior proposed in sorbet#8079.

Co-authored-by: Cameron Bothner cbothner@users.noreply.github.com
Co-authored-by: Claude noreply@anthropic.com

@julianojulio
julianojulio force-pushed the ae-task-21-typed-data-define-members-via-inline-rbs branch 4 times, most recently from 2de5e81 to 6c190da Compare March 24, 2026 14:27
Add support for typing Data.define members using inline RBS comments
on the symbol arguments:

    Point = Data.define(
      :x, #: Integer
      :y, #: String
    )

This gives Data.define members their declared types at zero runtime
cost -- the type annotations are in comments that only the static
analyzer reads.

The implementation has three parts:

1. CommentsAssociator: detect Data.define sends and capture #:
   comments on each symbol argument. Stored in a new
   dataDefineMembersForNode map in CommentMap.

2. SigsRewriter: when a Data.define has typed members, synthesize a
   sig + bare-super def initialize into the block body. This is
   the only code the Data rewriter needs to propagate types.

3. Data.cc: port Cameron Bothner's approach from sorbet#8079
   to extract types from the initialize sig and create typed reader
   stubs. The bare-super check ensures types only propagate when
   the initialize forwards all arguments unchanged.

Design decisions (informed by jez's review of sorbet#8079):

- Bare super required: types propagate to readers only when the
  initialize body is exactly bare super. When values are transformed
  (e.g., super(x: x.to_i)), readers fall back to T.untyped.

- self.[] left untyped: Sorbet can't overload positional vs keyword
  args, so only new/initialize gets typed.

- Explicit initialize takes precedence: if the user provides both
  inline #: types AND a def initialize with sig in the block, the
  explicit initialize wins.

Based on the typed Data.define approach originally designed by
Cameron Bothner in sorbet#8079.

Co-authored-by: Cameron Bothner <cbothner@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
@julianojulio
julianojulio force-pushed the ae-task-21-typed-data-define-members-via-inline-rbs branch from 6c190da to 15eaa0a Compare May 29, 2026 22:56
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.

1 participant