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:

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.

I want more typed code to be typed I want Sorbet to be applied to other Shopify codebases I want a friendlier syntax

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):

  1. parser: Converts Ruby files into an Abstract Syntax Tree (AST).
  2. desugarer: Simplifies the AST by removing syntactic sugar (e.g., converting unless <cond> to if !<cond>).
  3. rewriter: Further transforms the AST by rewriting specific Ruby DSLs and metaprogramming into analyzable code (e.g., turning attr_reader into plain def methods).
  4. resolver: Resolves constant references, names, and their relationships like class inheritance and module inclusion.
  5. cfg: Constructs a control flow graph from the AST.
  6. 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!