Inline RBS comments support for Sorbet
Sorbet has greatly improved how we read, understand, and maintain code at Shopify. But let’s face it—sometimes, the syntax isn’t the friendliest.
In this post, I’ll walk you through how we added support for inline RBS comments into Sorbet, preserving Sorbet’s strong type safety and type checking speed while adopting the cleaner syntax of RBS, making Ruby development smoother and more enjoyable for us.
Sorbet at Shopify
Sorbet is a type checking tool for Ruby that enables developers to gradually add static type annotations, improving code stability and reliability by catching errors early and facilitating refactoring. It also includes a language server, enhancing the development experience with features like code navigation and error diagnostics.
At Shopify, Sorbet has been instrumental in improving the readability and maintainability of our large codebase. Currently, Sorbet checks 99% of our 75,000 files, with type signatures on 71% of our 1.5 million methods, covering 61% of all method call sites. This extensive use boosts type safety and clarity while significantly reducing errors in production.
For more insights into our Sorbet journey, check these resources:
- Adopting Sorbet at Scale (RubyConf 2019)
- Static Typing for Ruby
- Adopting Sorbet at Scale
- Gradual Typing in Ruby - A Three Year Retrospective
Ruby Developer Experience
My team is focused on enhancing the Ruby Developer Experience at Shopify with innovative tooling.
Here are some of the tools we’ve developed to support Ruby developers:
-
Ruby LSP: Improves IDE experiences with language server protocol capabilities like “go to definition”, documentation on hover, automated refactoring, and more, facilitating smoother development workflows.
-
Tapioca: Automates type definitions (RBI files) generation for gems and domain-specific languages (DSLs), simplifying type integration with Sorbet.
-
Spoom: Offers static analysis for Ruby code, including identifying dead code to improve code quality.
Beyond our own tooling, we contribute to the broader Ruby ecosystem by enhancing the Ruby debugger and advancing RDoc, ensuring they remain effective resources for developers.
To align with the needs of our Ruby developer community at Shopify, we conduct regular surveys for insights and feedback. These surveys highlight beneficial areas and room for improvement. Over time, we’ve identified clear trends:
-
80% of our developers want more code to be typed, highlighting the benefits of type annotations for readability and maintainability.
-
71% support typing more codebases to take full advantage of Sorbet’s features, such as type checking and enhanced code navigation.
-
However, nearly 75% desire a more user-friendly and readable syntax that seamlessly integrates into their existing development workflows.
![]() |
![]() |
![]() |
A taste of Sorbet
Here’s a basic example of how Sorbet can be used in a Ruby codebase:
require "sorbet-runtime"
extend T::Sig
sig { params(names: T::Array[String]).returns(String) }
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
extend T::Sig
sig { returns(T.nilable(String)) }
attr_reader :nickname
sig { params(name: String, nickname: T.nilable(String)).void }
def initialize(name, nickname = nil)
@name = T.let(name, String)
@nickname = T.let(nickname, T.nilable(String))
end
end
While Sorbet is powerful, its use of Ruby as a Domain-Specific Language (DSL) for expressing types can be verbose and may clutter the codebase. In our example, we use sig {}
for method and accessor signatures and T.let
for typing variables. Using Ruby’s syntax, exemplified by T::Array[String]
and T.nilable(String)
, can add complexity due to its inherent limitations.
Additionally, Sorbet’s reliance on the sorbet-runtime
gem introduces performance overhead, leading us to disable runtime type checking in production environments.
Feedback from our developers highlighted the need for a more concise and unified syntax. And we agreed!
Ruby RBS
Recognizing the need for a more concise syntax, we turned our attention to RBS, a type definition language introduced in Ruby 3.0. RBS allows developers to describe types in their Ruby programs more clearly and succinctly. You can learn more about RBS in the official repository and Ruby’s announcement blog.
Here’s how RBS can be used to describe types in a Ruby program:
# file: user.rbs
class Object
def greet: (Array[String]) -> String
end
class User
attr_reader nickname: String?
def initialize: (String name, String? nickname) -> void
end
# file: user.rb
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
attr_reader :nickname
def initialize(name, nickname = nil)
@name = name
@nickname = nickname
end
end
RBS separates type definitions from code, requiring a .rbs
file as a companion to each .rb
file. This approach poses challenges in managing duplication across our 75,000-file codebase. Additionally, RBS does not directly support typing local variables and initially lacked a scalable type checker for large codebases — a significant limitation.
Nevertheless, many features of Sorbet align well with RBS, as I discuss in detail in my RubyKaigi 2023 talk. Therefore, integrating RBS into Sorbet was a natural progression, allowing us to leverage the strengths of both approaches.
Inline RBS comments
Our vision was to create a concise syntax for type annotations directly within Ruby files. Here’s the result we aimed for:
#: (Array[String]) -> String
def greet(names)
"Hello, #{names.join(", ")}!"
end
class User
#: String?
attr_reader :nickname
#: (String, String?) -> void
def initialize(name, nickname = nil)
@name = name #: String
@nickname = nickname #: String?
end
end
This syntax, based on RBS, provides a clearer and more direct way to express type annotations, reducing clutter and enhancing code readability. Additionally, because it is comments-based, it involves no runtime dependency.
Implementing RBS support in Sorbet
How did we bring our vision to reality? By weaving RBS parsing and rewriting into Sorbet’s type-checking pipeline.
Initially, the pipeline processes Ruby files through several stages (simplified here):
parser
: Converts Ruby files into an Abstract Syntax Tree (AST).desugarer
: Simplifies the AST by removing syntactic sugar (e.g., convertingunless <cond>
toif !<cond>
).rewriter
: Further transforms the AST by rewriting specific Ruby DSLs and metaprogramming into analyzable code (e.g., turningattr_reader
into plaindef
methods).resolver
: Resolves constant references, names, and their relationships like class inheritance and module inclusion.cfg
: Constructs a control flow graph from the AST.type_checker
: Flows type information through the CFG to infer types and raise errors when type safety is violated.
For more insight, explore the Sorbet internals documentation and an article by my colleague Emily on adding Ruby 3.2 support to Sorbet.
For inline RBS comments, we introduced a new phase between the parser
and the desugarer
. In this phase, we locate RBS comments and associate them with the relevant AST nodes. We then parse the RBS content and generate equivalent Sorbet sig
and T.let
constructs directly within the AST, as if they were written in the Ruby files from the start. This process ensures that the remainder of the Sorbet pipeline functions seamlessly.
To visualize the output of each phase, you can run Sorbet with the --print <format>
flag. For example, to see the AST after the desugarer
phase, run:
$ srb tc --print desugar-tree user.rb
Here’s an excerpt showing the initialize
method after our RBS rewrite inserted the sig
and T.let
nodes into the AST from the RBS comments:
::<root>::<C Sorbet>::<C Private>::<C Static>.sig(::<root>::<C T>::<C Sig>::<C WithoutRuntime>) do ||
<self>.params(:name, <emptyTree>::<C String>, :nickname, ::<root>::<C T>.nilable(<emptyTree>::<C String>)).void()
end
def initialize(name, nickname = nil, &<blk>)
begin
@name = ::<root>::<C T>.let(name, <emptyTree>::<C String>)
@nickname = ::<root>::<C T>.let(nickname, ::<root>::<C T>.nilable(<emptyTree>::<C String>))
end
end
RBS comments
Our work on inline RBS comments is ongoing, but several features are already available.
Sorbet now supports RBS signature comments for instance methods and singleton methods:
#: (Array[String]) -> String
def greet(names)
"Hello, #{names.join(", ")}!"
end
Each #:
introduces a new RBS signature. Longer signatures can be spread across multiple lines with #|
:
#: (
#| String name,
#| String? nickname
#| ) -> void
def initialize(name, nickname = nil); end
Attributes can also be typed using RBS comments:
#: String?
attr_reader :nickname
You can specify types for local, global, instance, class variables, and constants with a trailing #:
comment:
local = ARGV.first #: String?
$global = ARGV.first #: String?
@instance = ARGV.first #: String?
@@class = ARGV.first #: String?
CONSTANT = ARGV.first #: String?
This is equivalent to using T.let
:
local = T.let(ARGV.first, T.nilable(String))
$global = T.let(ARGV.first, T.nilable(String))
@instance = T.let(ARGV.first, T.nilable(String))
@@class = T.let(ARGV.first, T.nilable(String))
CONSTANT = T.let(ARGV.first, T.nilable(String))
Type casting can be achieved using the #: as
comment:
name = ARGV.first #: as String
This is equivalent to using T.cast
:
name = T.cast(ARGV.first, String)
For casting to non-nilable types, use #: as !nil
:
name = ARGV.first #: as !nil
This is equivalent to using T.must
:
name = T.must(ARGV.first)
Type assertions using RBS comments can be applied wherever T.let
, T.cast
, or T.must
are currently used. To introduce a type assertion in the middle of an expression, you can break the expression across multiple lines:
greet(
[
ARGV.first, #: as !nil
"Alex"
] #: as Array[String]
) #: as String
This corresponds to:
T.cast(
greet(
T.cast(
[
T.must(ARGV.first),
"Alex"
],
Array[String]
),
String
)
)
RBS type assertion comments also work for call receivers:
ARGV #: as Array[String]
.first #: as !nil
.chars
Which is equivalent to:
T.must(T.cast(ARGV, Array[String]).first).chars
Try it yourself
RBS support in Sorbet is still evolving and is currently experimental. To enable it, you’ll need to opt-in by passing the --enable-experimental-rbs-signatures
and --enable-experimental-rbs-assertions
options to Sorbet, or by adding these options to your sorbet/config
file.
For more information, you can refer to Sorbet’s RBS support documentation. You can also explore the Sorbet playground to see RBS in action.
Migrating to RBS comments
Manually migrating a large codebase to RBS is a complex task. To ease this process, we’ve built automation tools into Spoom to help you translate existing Sorbet signatures and assertions to RBS with ease:
$ spoom srb sigs translate
$ spoom srb assertions translate
Keep in mind that Sorbet supports both RBS comments and the traditional sig {}
syntax, allowing for a gradual migration to the new syntax.
For insight into how we transitioned some of our open source projects to RBS comments, check out these examples:
I recently presented this work at RubyKaigi 2025, discussing the integration process and supported RBS features in more detail. Stay tuned for the video of the talk, which will be released soon. You can find more details here.
Happy typing!