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
- 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
Then, add the Sorbet gem to your Gemfile:
source 'https://rubygems.org' gem 'sorbet', group: :development
- Install Sorbet: Install the Sorbet gem using Bundler:
bundle install
- Verify the installation: Check that Sorbet is installed correctly by running:
bundle exec srb
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 oursorbet-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
- Initialize Sorbet: Initialize your directory with Sorbet by running:
srb init
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 theperson.rb
file totrue
:
# typed: true
- 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
Correcting this typo to name
and rerunning the command should output the following:
No errors! Great job.
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
- Within the file, call this method as such:
Person.new(8).name_length
- 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 ^^^^^^^
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
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
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 ^^^^^
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
After running bundle install
, require sorbet-runtime
in our file:
# person.rb require 'sorbet-runtime'
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
andsorbet-runtime
.
gem 'tapioca', require: false, group: [:development, :test]
- Initialize the project for use with Sorbet by running:
bundle exec tapioca init
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
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)`
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
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
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?)
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
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
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:
- Sorbet: Stripe’s type checker for Ruby
- Learn Sorbet in Y minutes
- Gradual typing for Ruby at Scale with Sorbet (video)
- Sorbet documentation
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!