Ruby’s hidden gems: Sorbet

tripplea

Abiodun Olowode

Posted on October 2, 2024

Ruby’s hidden gems: Sorbet

The debate between static and dynamically typed languages has long been a subject of contention among developers. Each approach offers its own set of advantages and disadvantages, significantly influencing the software development process.

Dynamically typed languages like Ruby provide flexibility by allowing variables to be declared without corresponding types. This approach fosters rapid development and promotes an agile process.

Yet, the absence of strict typing can lead to challenges, such as runtime errors that may be harder to debug and maintain in larger codebases. For example, in a dynamically typed language like Ruby, attempting to divide an array by a string only results in an error when the code is executed, making it potentially harder to identify and fix such issues.

In this article, we explore Sorbet, a type checker for Ruby, which addresses the challenges of dynamic typing in Ruby, enhancing code reliability and maintainability without sacrificing the language's flexibility and expressiveness.

What is Sorbet for Ruby?

Sorbet, implemented in C++, is a Ruby gem designed to harmonize the dynamism of Ruby with the reliability and predictability of static typing. As Ruby projects scale in size and complexity, maintaining code quality and preventing errors becomes increasingly challenging. A primary culprit is the absence of static typing, which often necessitates heavy reliance on extensive testing and runtime checks to ensure code correctness, resulting in more frequent bugs slipping into production.

Developed by Stripe, Sorbet seeks to tackle these challenges by introducing static typing to Ruby. It functions as a type checker and gradual type system for Ruby, enabling the annotation of code with type information and the detection of errors at compile time (rather than runtime).

Key features and benefits of Sorbet include:

  • Type Declaration: Sorbet allows for the declaration of types for variables, method parameters, and return values. This facilitates early error detection and enhances code readability.
  • Gradual Typing: Unlike statically typed languages where typing is mandatory, Sorbet permits the incremental introduction of type annotations. This means existing Ruby codebases can transition gradually to a statically typed workflow without needing a complete rewrite.
  • Instantaneous Feedback: During development, Sorbet provides instant insights into method definitions and usage. Clicking on a method reveals its definition, while hovering over it displays information about its input and return types. This real-time feedback accelerates development and enhances code navigation.
  • Type Inference: Sorbet employs a straightforward type inference algorithm to deduce types where explicit annotations are absent. This minimizes the need for manual type annotations and facilitates the adoption of static typing in Ruby projects.
  • Tooling Integration: Sorbet seamlessly integrates with popular Ruby development tools, including editors, IDEs, and build systems. This ensures a seamless developer experience and promotes the adoption of static typing practices within Ruby development workflows.

Getting Started with Sorbet

Before diving into Sorbet's integration into your codebase, it's helpful to explore a Sorbet playground, which provides a sandbox environment to experiment with Sorbet's features. Here's a Sorbet playground that allows you to tweak code and see how Sorbet responds to various changes.

To understand Sorbet and its functionality, before applying it to an existing codebase, let's start a new Ruby project:

  • Create a new directory: Begin by creating a new directory named sorbet-test where we'll set up our Sorbet testing environment.
mkdir sorbet-test
cd sorbet-test
Enter fullscreen mode Exit fullscreen mode
  • Set up the Gemfile: If you don't already have a Gemfile in your sorbet-test directory, create one using the following command:
touch Gemfile
Enter fullscreen mode Exit fullscreen mode

Then, add the Sorbet gem to your Gemfile:

source 'https://rubygems.org'

gem 'sorbet', group: :development
Enter fullscreen mode Exit fullscreen mode
  • Install Sorbet: Install the Sorbet gem using Bundler:
bundle install
Enter fullscreen mode Exit fullscreen mode
  • Verify the installation: Check that Sorbet is installed correctly by running:
bundle exec srb
Enter fullscreen mode Exit fullscreen mode

This should give an output that indicates that no sorbet/ directory was found and prompts us to initialize our directory with 'srb init'.

  • Create a Ruby file: Now, let's create a new Ruby file named person.rb in our sorbet-test directory, and add the following code:
class Person
 attr_accessor :name

  def initialize(name)
    @name = name
  end

  def check_name
    if nam == 'John'
      puts 'This person is John'
    else
      puts 'This person is not John'
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
  • Initialize Sorbet: Initialize your directory with Sorbet by running:
srb init
Enter fullscreen mode Exit fullscreen mode

This will create a sorbet folder with an rbi subfolder in our directory. You'll also find the # typed: false sigil appended to the person.rb file.
This sigil indicates to Sorbet what errors to report and which to silence (# typed: ignore being the least, as it causes Sorbet to ignore the file, and # typed: strong being the strictest, as all errors in this file are reported). Read more detailed information about these sigils.

  • Update typed sigil: Update the # typed sigil in the person.rb file to true:
# typed: true
Enter fullscreen mode Exit fullscreen mode
  • Run Sorbet: Finally, run Sorbet with bundle exec srb to check your Ruby file for type errors.

The following error should ensue:

person.rb:10: Method nam does not exist on Person https://srb.help/7003
    10 |    if nam == 'John'
               ^^^
  Did you mean name? Use -a to autocorrect
    person.rb:10: Replace with name
    10 |    if nam == 'John'
               ^^^
    person.rb:3: Defined here
     3 | attr_accessor :name
         ^^^^^^^^^^^^^^^^^^^
Errors: 1
Enter fullscreen mode Exit fullscreen mode

Correcting this typo to name and rerunning the command should output the following:

No errors! Great job.
Enter fullscreen mode Exit fullscreen mode

Type Mismatches in Sorbet

Let's see another example of how Sorbet detects a type mismatch.

  • Add this new method to person.rb file:
def name_length
  puts name.length
end
Enter fullscreen mode Exit fullscreen mode
  • Within the file, call this method as such:
Person.new(8).name_length
Enter fullscreen mode Exit fullscreen mode
  • Run Sorbet with bundle exec srb.

We do not get any errors. However, when we run this code, we get the following error:

/.../person.rb:18:in `name_length': undefined method `length' for 8:Integer (NoMethodError)

    puts name.length
             ^^^^^^^
Enter fullscreen mode Exit fullscreen mode

Sorbet should ideally catch this error, but it fails because it lacks the necessary information about the expected name type. To provide Sorbet with this information, we need to add type signatures to our methods.

Adding Type Signatures

We can enable type signatures in our code by extending the T::Sig module and adding signatures to methods. Let's add a signature to the initialize method:

class Person
  extend T::Sig

  sig {params(name: String).void}
  def initialize(name)
    @name = name
  end
end
Enter fullscreen mode Exit fullscreen mode

This signature is basically telling Sorbet that this method takes a parameter name of type String and returns nothing.

Now, running bundle exec srb outputs the following error:

person.rb:24: Expected String but found Integer(8) for argument name https://srb.help/7002
    24 |Person.new(8).name_length
                   ^
  Expected String for argument name of method Person#initialize:
    person.rb:6:
     6 |  sig {params(name: String).void}
                      ^^^^
  Got Integer(8) originating from:
    person.rb:24:
    24 |Person.new(8).name_length
Enter fullscreen mode Exit fullscreen mode

Sorbet successfully detects the error due to the addition of type signatures. Updating the sigil to # typed: strict, requires all methods to possess a type signature.

Sorbet Runtime Support

Although Sorbet is able to carry out its checks successfully, we're not able to run this piece of code. We get the following error:

/.../person.rb:3:in `<class:Person>': uninitialized constant Person::T (NameError)

  extend T::Sig
          ^^^^^
Enter fullscreen mode Exit fullscreen mode

This error indicates that Sorbet's type annotations, represented by the T module, were not found. Our piece of code requires access to the necessary runtime support for Sorbet's type annotations, which allows it to run correctly alongside Sorbet's static type checking.

To resolve this, we need to add the sorbet-runtime gem to our project:

gem 'sorbet-runtime', group: :development
Enter fullscreen mode Exit fullscreen mode

After running bundle install, require sorbet-runtime in our file:

# person.rb
require 'sorbet-runtime'
Enter fullscreen mode Exit fullscreen mode

With sorbet-runtime loaded, our code runs correctly, and Sorbet effectively enforces type safety.

Sorbet IDE Integration

Running bundle exec srb tc frequently to typecheck code can be tedious and time-consuming. To streamline the development process, Sorbet offers a VSCode extension that provides various features to enhance your coding experience. These features include autocomplete, jump to definition, type information and documentation on hover, sig suggestion, quick fixes, autocorrection, static error displays, and more. Find out more about how to install and use the VSCode extension.

For developers using editors other than VS Code, Sorbet does not have official integrations or extensions. However, some editors support the Language Server Protocol (LSP), allowing language servers like Sorbet to integrate with them. JetBrains IDEs, including IntelliJ IDEA, PyCharm, and RubyMine, have built-in support for the LSP. Additionally, there are plugins available for Sublime Text and Atom that enable LSP support. While these solutions may not offer the same level of integration and features as the dedicated Sorbet extension for VSCode, they can still provide basic functionality, such as syntax highlighting, autocompletion, and error checking.

Exploring Tapioca

We've gone through the process of adding types to a new project. However, what about projects already in existence? Typically, these projects come with pre-installed gems, and these third-party services include methods invoked within our project. How do we guarantee that we're passing the correct types of parameters to these methods, or that they're being invoked on appropriate types? The answer to this is by using Tapioca. To understand what Tapioca does, it's important to first understand what Ruby Interface (RBI) files are.

What Are Ruby Interface (RBI) Files?

RBI means Ruby Interface, and RBI files serve as interface files that provide documentation on type information for Ruby code. They contain declarations of types, method signatures, and other type-related metadata. They can either be created manually or autogenerated.

While Sorbet is capable of inferring types to some extent, there are scenarios for which Sorbet lacks sufficient information. For example, when dealing with complex inheritance hierarchies, method overrides, external dependencies, or dynamic method calls, Sorbet may struggle to infer types accurately without explicit type annotations provided by RBI files.

What Does Tapioca Do?

Tapioca, a Ruby gem developed by Shopify, streamlines the creation of RBI files, which are essential for implementing gradual typing in Ruby projects. These RBI files can cover not only the gems used within the application but also the methods available within Rails, along with other DSLs and metaprogramming paradigms.

To integrate Tapioca into an existing Rails project for use with Sorbet, follow these steps:

  • Add the Tapioca gem to your Gemfile, alongside existing gems such as sorbet and sorbet-runtime.
gem 'tapioca', require: false, group: [:development, :test]
Enter fullscreen mode Exit fullscreen mode
  • Initialize the project for use with Sorbet by running:
bundle exec tapioca init
Enter fullscreen mode Exit fullscreen mode

This command generates a Sorbet folder structure within your project:

├── config             # Default options to be passed to Sorbet on every run
└── rbi/
  ├── annotations/     # Type definitions pulled from the rbi-central repository
  ├── gems/            # Autogenerated type definitions for your gems
  └── todo.rbi         # Constants which were still missing after RBI generation
└── tapioca/
  ├── config.yml       # Default options to be passed to Tapioca
  └── require.rb       # A file where you can make requires from gems that might be needed for gem RBI generation
Enter fullscreen mode Exit fullscreen mode

The terminal output provides valuable guidance on generating type definitions for DSLs in your application, performing type checking, and upgrading files from # typed: false to # typed: true using tools like Spoom. Take some time to review this information.

Let's illustrate with a simple example involving a Person model in your project. After adding # typed: true to a file, attempting to call Person.all triggers an error:

Method `all` does not exist on `T.class_of(Person)`
Enter fullscreen mode Exit fullscreen mode

With the Sorbet extension in your IDE, Person.all is highlighted in red, indicating an error. This occurs because Sorbet lacks awareness of Person as a model or the available Rails methods for the Person class. To resolve this, we need to generate an RBI file for the Person model. RBI files can be generated using the command:

bin/tapioca dsl
Enter fullscreen mode Exit fullscreen mode

Executing this command generates several RBI files, including person.rbi, which defines methods available to the Person class:

# typed: true

# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `Book`.
# Please instead update this file by running `bin/tapioca dsl Book`.

class Person
  include GeneratedAttributeMethods
  extend CommonRelationMethods
  extend GeneratedRelationMethods

  # Other methods related to Person class are stated here...

  module GeneratedAssociationRelationMethods
    sig { returns(PrivateAssociationRelation) }
    def all; end

    # Additional methods related to associations are stated here...
  end

  # More methods related to Person class are stated here...
end
Enter fullscreen mode Exit fullscreen mode

With the RBI file in place, the error disappears, as Sorbet now recognizes all methods available to Person. Any additions or changes to methods in Person can be reflected in the RBI file by rerunning the bin/tapioca dsl command.

Challenges with Sorbet

Gradually adding types to a codebase often leads to a mixed scenario where some parts are typed, and others aren't. Sorbet helps by detecting errors during runtime with its sorbet-runtime component. This is particularly valuable in identifying situations where types might be incorrect, such as when passing different types from an untyped section of code while expecting a specific type in a typed portion.

# typed: true
require 'sorbet-runtime'

class Person
  extend T::Sig

  def self.happy?
    true
  end

  sig {params(statement: String).returns(T::Boolean)}
  def self.the_truth?(statement)
    statement.length > 9 ? true : false
  end
end

Person.the_truth?(Person.happy?)
Enter fullscreen mode Exit fullscreen mode

Running bundle exec srb doesn't raise an error here because it's unaware of the happy? return type, which prevents it from accurately assessing whether the passed value is not a string. However, Sorbet runtime detects this issue and raises an error during runtime without the execution of the the_truth? method.

Parameter 'statement': Expected type String, got type TrueClass (TypeError)
Caller: person.rb:37
Definition: person.rb:31
Enter fullscreen mode Exit fullscreen mode

This runtime feedback is invaluable for correcting our code and making necessary adjustments. Yet, it's essential to recognize that Sorbet runtime incurs a performance overhead, which may not be suitable for all production environments. According to the Sorbet documentation on runtime:

[...]in some cases, especially when calling certain methods in tight loops or other latency-sensitive paths, the overhead of even doing
the checks (regardless of what happens on failure) is prohibitively expensive. To handle these cases, Sorbet offers .checked(...) which
declares in what environments a sig should be checked.

Sorbet provides various mechanisms to disable these runtime checks when needed. One such mechanism can be configured in application.rb:

T::Configuration.default_checked_level = :tests # where :tests is the environment, it can also be set to :never
Enter fullscreen mode Exit fullscreen mode

For further details on Sorbet runtime checks, refer to the Sorbet runtime documentation.

Wrapping Up

In this post, we've looked at Sorbet, exploring its fundamental workings, benefits, and some considerations regarding performance. Sorbet stands as a robust tool for type checking in Ruby, offering the flexibility of gradual adoption. Its vibrant ecosystem and ease of implementation make it an invaluable asset for Ruby developers seeking to enhance code reliability and maintainability.

For further exploration, consider diving into the resources provided below:

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

💖 💪 🙅 🚩
tripplea
Abiodun Olowode

Posted on October 2, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related