Bringing more sweetness to Ruby with Sorbet types 🍦

Cherry Ramatis - Sep 18 '23 - - Dev Community

Have you ever wanted to add type-checking to your Ruby code? Going from a rubber duck to a full-armed duck? (pun intended) no? So allow me to introduce you to type checking using the provided gem sorbet created by Stripe.

Table of contents

Why do we need types at all?

type check

If you have been in the Ruby community for the past couple of years, it's possible that you're not a super fan of types or that this concept never passed through your mind, and that's totally cool. I myself love the dynamic and meta-programming nature of Ruby, and honestly, by the time of this article's writing, we aren't on the level of OCaml for type checking and inference, but still, there are a couple of nice things that types with sorbet bring to the table:

  • 1. Writing less tests: Ok, this is a sensible topic for us TDD lovers, but writing less tests is not about abandoning tests all together, it is more about writing tests that actually matter by testing our business logic and important parts of our application, not by checking if the function a receives a number.
  • 2. Submitting code with more confidence: Throw your stones if you Ruby developer didn't get a 'mymethod': undefined method '-' for "test":String (NoMethodError) on production or on the PR pipeline (if your team likes to have weekends). The whole idea of designing proofs for your code is to solve exactly that situation, because now we have a type check step that ensures this type of bug most of the time.
  • 3. Trusting more on your environment: The Ruby community learned through the years to rely on "dumb code analysis tools" such as Ctags, Grep and find instead of smart code analysis tools (let's be honest, Solargraph isn't that great), but by bringing type proof to our code, it makes it possible to create better tools that analyze our code and provide "go to definition", "completion", "hover", etc. (a.k.a. sorbet LSP).

This piece is a personal opinion, but I firmly believe that embracing type systems is the right choice for modern software development. Perhaps, in the future, our cherished Ruby code will adhere to the principle of "If it compiles, it works," like the outside world (Haskell, OCaml, Elm, etc.).

How sorbet aims to introduce types?

First let's introduce the tool: Sorbet is a gem developed by Stripe that aims to bring type notation syntax and type checking support for the Ruby ecosystem by utilizing the "Gradual typing" philosophy, it also provide type generation from YARD comments via the tapioca gem, allowing to grow alongside the already built Ruby codebase.

Ok but what is gradual typing?

Gradual typing is a term that defines a type system that coexists with the idea of untyped, where untyped is a part of the code that the compiler needs to ignore at some level (such as any for typescript and mixed for PHP). This is necessary when developing a type system on top of dynamic languages because it's impossible to just throw away all the previously written code to allow type checking.

Sorbet also goes beyond the untyped approach by allowing the developer to enable type checking per file. That way, you can absolutely control the areas where you want to type check and the level of strictness you want, as shown below:

  • ignore: By adding the comment # typed: ignore to your file, you tell Sorbet to absolutely ignore that file and its possible errors. It's obvious that this is not recommended at all, right?
  • false: This is the default state sorbet assumes even if your file doesn't have any comments, and it only reports errors on the syntax (like when you type deff instead of def).
  • true: Here is where the fun begins, adding # typed: true to your file enables full type checking, but assumes T.untyped for all code that doesn't have the sig annotation defined.
  • strict: The strict mode disables the untyped for created code and enforces the type notation on the whole file. The general tip is: "If your file works with typed: strict, just let it be".
  • strong: While the previous strict mode allowed you to explicitly tell that a function is untyped, in the strong mode you can't even do that and everything should have a proper type. This mode is cool for experimentation, but I wouldn't use it on production code.

What about ruby 3?

Recently, we saw Matz talking about the new RBS solution that should bring type checking to Ruby, and although this is super cool, it's quite new, and the approach has some problems in my opinion, such as:

  • 1. Lack of LSP: Since this new type check solution is quite new (at the time of writing), we don't have nice editor support via LSP. Things like steep will probably solve this in the future, but it's not a reliable solution now. On the other hand, Sorbet has existed for many years on the market and already provides a lot of tools for code intelligence, you can see more in this blog post.
  • 2. No support for inline types: The Ruby 3 type system forces you to define the type proof on a separate rbs file, this solves part of the problem of typing classes and functions but makes it quite impossible to type declared variables (something that you probably want to do). Sorbet allows such things via the T.let function.
  • 3. Missing type generation tools: Differently from the sorbet solution that provides the tapioca gem to generate types from YARD docs, allowing the gradual typing of your code base, the Ruby rbs format doesn't provide any official solution on that matter and makes the life of early adopters quite difficult.

Editor support and configuration

Vim meme

Now that I've (hopefully) explained my arguments in favor of sorbet to you, reader, let's look into more practical points, such as the editor support while using sorbet on the codebase. The basic thing to understand is that the same gem used for type checking can be used for hosting an LSP server by passing the --lsp flag to it.

  • The standard editor for web people (unfortunately); VS Code has official support via the extension, and it's even more detailed on this official blog post.
  • More heavyweight IDEs like RubyMine also provide official support for sorbet with a extension.
  • Also my personal and beloved choice; (neo)vim provides support by using any LSP plugin, the main ones also provide official support, such as nvim lspconfig or vim ale.

Disclaimer: I didn't find any support for TextMate or sublime, sorry :(

Getting our hands dirty! How to create a sample API with sorbet

Enough with the chatter; it's time to dive into some code. Let's code a simple API using Sinatra to both demonstrate the potential benefits and challenges of achieving type correctness with Sorbet.

Info: I'll be using a simplified version of the architecture presented on my previous article, if you want to know with more context just look at: https://dev.to/cherryramatis/creating-a-sinatra-api-with-system-wide-dependency-injection-using-dry-system-10mp

Creating a new project and initializing sorbet

Since this part is well known in all my articles, let's speed run a basic Ruby project setup using Sinatra and Zeitwerk for auto-requiring code:

To begin, let's create a new Ruby project with the necessary dependencies as shown below:

Disclaimer: The sorbet dependencies will be in a dedicated part



$ mkdir myproject && cd myproject
$ bundle init
$ bundle add zeitwerk sinatra puma pry pry-reload


Enter fullscreen mode Exit fullscreen mode

With our basic dependencies already installed, it's time to configure Zeitwerk to handle the automatic requirement of our files. To do this, create a file at config/application.rb and populate it with the following content:



# frozen_string_literal: true

require 'zeitwerk'
require 'pathname'

root = Pathname('.')

loader = Zeitwerk::Loader.new

loader.push_dir(root.join('lib').realpath)
loader.push_dir(root.join('config').realpath)

loader.setup


Enter fullscreen mode Exit fullscreen mode

We'll enhance the project with basic REPL (Read-Eval-Print Loop) support using Pry. To implement this, create a file at bin/console and include the following content:



#!/usr/bin/env ruby

require 'sorbet-runtime'
require_relative '../config/application'

require 'pry-reload'
require 'pry'
Pry.start


Enter fullscreen mode Exit fullscreen mode

Disclaimer: The inclusion of the sorbet-runtime here allow us to properly run our code with all the type notation that we'll see further.

Tip: Don't forget to run chmod +x ./bin/console to make your script executable.

After configuring the necessary things for any ruby project let's work on our sorbet environment, start by opening the Gemfile file and adding the following content:



gem 'sorbet-runtime'

group :development do
  gem 'sorbet'
  gem 'tapioca', require: false
end


Enter fullscreen mode Exit fullscreen mode

In this configuration, we're placing both sorbet and tapioca within the development group. This is because in production, we will exclude type checking (as it should only run on your local machine). However, sorbet-runtime is included in all environments to facilitate running code with the specific type notation syntax.

To proceed the setup, let's initialize the sorbet environment with the following commands:



$ bundle exec tapioca init


Enter fullscreen mode Exit fullscreen mode

Disclaimer: Don't add the sorbet/ folder to your .gitignore file, it's important to keep under version control because you can edit those files or create new ones using the *.rbi syntax.

To generate the required type files for our installed gems, run the following command at your shell:



$ bundle exec tapioca gems


Enter fullscreen mode Exit fullscreen mode

To test your installation and see if it's working as it should, you can run this command at your shell:



$ bundle exec srb tc


Enter fullscreen mode Exit fullscreen mode

You should see the message No errors! Great job. displayed at your stdout, as a signal that our setup work until this point has been successfully completed πŸ’.

Defining our main router file

Since we want to build a basic API, let's define a router class located at config/router.rb with the following content:

Notice that we're using the # typed: true comment, so we should be getting all the type goodness from the LSP!



# frozen_string_literal: true
# typed: true

require 'json'
require 'sinatra/base'

class Router < Sinatra::Base
  get '/' do
    JSON.dump({ message: 'Hello World' })
  end
end


Enter fullscreen mode Exit fullscreen mode

With our routes defined, we need a basic server, right? That's where Puma comes in to save the day! To initialize the Puma server, we'll create a config.ru file on the root of our project with the following content:



# frozen_string_literal: true

require 'sorbet-runtime'
require_relative 'config/application'

Router.run!


Enter fullscreen mode Exit fullscreen mode

Creating a service

You might be thinking to yourself, "That's all well and good, but where's the type checking?" Not to worry! I've got you covered. Let's create a new file under lib/services/hello_world_service.rb with the following content:



# frozen_string_literal: true
# typed: true

module Services
  class HelloWorldService
    extend T::Sig

    sig { params(name: T.nilable(String), lang: T.nilable(String)).returns(T::Hash[Symbol, String]) }
    def call(name, lang)
      predicate = case lang
                  when 'pt'
                    'Ola'
                  else
                    'Hello'
                  end

      return { message: "#{predicate} anon" } if name.nil?

      { message: "#{predicate} #{name}" }
    end
  end
end


Enter fullscreen mode Exit fullscreen mode

Let's gooo now we're talking about! Isn't this syntax just fantastic? Personally, I find it quite appealing.

To enable the sig syntax, we begin by extending the T::Sig module within our class. As you can see, there are several options available to us when it comes to describing the type correctness of a method using the sig syntax, as demonstrated below:



sig { params().returns() }
sig { returns() }


Enter fullscreen mode Exit fullscreen mode

You know what best part is? This is 100% valid Ruby syntax! That means you don't require any special syntax highlighters or transpilers – just good old-fashioned and beautiful Ruby code.

In addition to the built-in types provided by the language, such as String and Integer, we have a range of constructs available under the T identifier to express more complex types. Here are some key ones that, in my opinion, are crucial to be aware of:

  • T.nilable: This tells the type checker that a value can be nil and will further error you out to add a guard using the nil? method or by convincing the checker to trust you with T.must.
    • T.must: If you can't or don't want to add an if-clause, you can enforce to the compiler that something is not nil by using the T.must syntax like so: T.must(something_not_nilable), this is more like a "trust me" statement.
  • T::Hash: This allows us to express the type for a hash by providing both key and value types, like so: T::Hash[String, Integer]
  • T::Array: The same thing as hash but for arrays, can be used like: T::Array[String].
  • T.let: This is where Sorbet shines, in my humble opinion, because it enables inline typing for variables like: foo = T.let(nil, T.nilable(String)).

Back to our code, since we're receiving both parameters name and lang on our method, we'll need to modify our router at config/router.rb:



# frozen_string_literal: true
# typed: true

require 'json'
require 'sinatra/base'

class Router < Sinatra::Base
  get '/:name/:lang' do
    return JSON.dump({ error: 'Provide correct params' }) if params.nil?

    response = Services::HelloWorldService.new.call(T.must(params)['name'], T.must(params)['lang'])

    JSON.dump(response)
  end
end


Enter fullscreen mode Exit fullscreen mode

All good, right? But if you run the command bundle exec srb tc right now, you'll get the following error:



config/router.rb:9: Method params does not exist on T.class_of(Router) https://srb.help/7003


Enter fullscreen mode Exit fullscreen mode

Since params is a special variable provided by the Sinatra gem, sorbet couldn't infer any type and gave us an error, but don't worry! Let's introduce a cool concept that you'll use a lot while using Sorbet, called shims.

Shims are rbi files that we declare to provide the types where sorbet couldn't infer by itself. With that in mind let's create a file located at sorbet/rbi/shims/sinatra_base.rbi with the following content:

This is why you need to keep the sorbet/ folder under version control!



# typed: true

module Sinatra
  class Base
    extend T::Sig

    sig { returns(T.nilable(T::Hash[String, String])) }
    def self.params; end
  end
end


Enter fullscreen mode Exit fullscreen mode

Now we're declaring the params as a possibly nullable hash with both key and value as Strings, like this:



{"name" => "Cherry Ramatis", "lang" => "pt"}


Enter fullscreen mode Exit fullscreen mode

You can observe that on the router file we're using the T.must to tell the checker that although it's possible for params to return a nil, at the time of running the route it'll not be possible to have any nil value.

But why didn't we add something like return if params.nil? you may be thinking to yourself. In this particular case it's not possible because params is a method and not a variable, of course, you can adjust the shim type or even create a variable receiving params, but in this case, I preferred to just use the T.must clause!

Last but not least we have our extremely complex API working:

Conclusion

I hope you can get something useful from this comprehensive guide to sorbet. My main goal was to provide an introduction to this whole ecosystem of new ideas and hopefully encourage more people to try and create the bright future of type checking in Ruby!

You can get more information about the more advanced possibilities of Sorbet sig syntax for type notation from this documentation. May the force be with you!

. . . . . . . . . . . . . . . . . . . . . .