5 minute read

Introduction

Hello everyone!

I started this year by re-learning the Ruby programming language. Ruby is a lovely and elegant programming language, and It was one of my first three languages that I’ve learned back in the day on CodeCademy.

In January, I finished reading the Well-Grounded Rubyist, Fourth Edition book which got me a good start with Ruby. I’m by no means proficient in it yet, but I’ve also bought several Ruby books that I hope to finish reading this year.

The reason for learning a new language every year is that I find it enjoyable to try new things and read books.
In the last three years I’ve read about Rust, Kotlin and Dart, and I’ve also done a few side projects in them.

Back when I was learning Python we had no types, everything was blank, and it was difficult to tell what a method was doing by just looking at its signature, luckily for us PEP 484 added type hints, and we could write code like this:

def greeting(name: str) -> str:    return 'Hello ' + name

Ruby also has support for types and the reason I’m writing this article is to show you how to add types to your Ruby project using Sorbet and RBS.

Both approaches are different, let’s explore!

Types with RBS

RBS the Ruby Signature started development 7 years ago, and it works by having a separate .rbs file in which you specify the types of your Ruby scripts.

To install RBS you will need to install the rbs gem:

gem install rbs

MinStack Example

Let’s add types to the following Ruby code that is present in the ./lib/minstack.rb file:

# frozen_string_literal: trueclass MinStack  def initialize    @stack = []    @min_stack = []  end  def push(value)    minimum_value = @min_stack.empty? ? value : [value, @min_stack.last].min    @stack << value    @min_stack << minimum_value  end  def top    @stack.last  end  def pop    @stack.pop    @min_stack.pop  end  def get_minimum    @min_stack.last  endenddef main  puts 'Hello world'  stack = MinStack.new  stack.push(5)  stack.push(-10)  stack.push(-1)  stack.push(20)  stack.pop  puts "Top is #{stack.top&.to_s}"  puts "Min is #{stack.get_minimum&.to_s}"endmain if __FILE__ == $PROGRAM_NAME

We would need to create a new directory in the project root called sig and then create the minstack.rbs file with the following contents:

class MinStack  @stack: Array[Integer]  @min_stack: Array[Integer]  def initialize: () -> void  def push: (Integer value) -> void  def top: () -> Integer?  def pop: () -> Integer?  def get_minimum: () -> Integer?endclass Object  private def main: () -> voidend

And this is pretty similar to Python you have the function_name(): type -> return_value and ? means the type is optional.

If you’re using RubyMine for development it was RBS integration, and it tells you which variables and functions are missing types.

Another interesting thing here is the following code:

class Object  private def main: () -> voidend

Each global method in Ruby becomes a private instance of the Object class, if we don’t add those lines to the .rbs file we won’t have a typed main method.

Static Type Checking

For static type checking with RBS you will need and additional gem called steep. After installing it you need to init the steep.

gem install steepsteep init

The init command will generate a Stepfile which you can edit in order to configure static type checking:

target :app do  # 1. The code you want to type check  check "lib"      # Verify files in the lib directory  # 2. Where your RBS signatures are located  signature "sig"end

Now you can execute step check and you will have type checking:

➜  steep check # Type checking files:..No type error detected. 🫖

You can also change the parameter from a push call from int to string and execute the step check again and you will get an error.

lib/minstack.rb:35:13: [error] Cannot pass a value of type `::String` as an argument of type `::Integer`│   ::String <: ::Integer│     ::Object <: ::Integer│       ::BasicObject <: ::Integer││ Diagnostic ID: Ruby::ArgumentTypeMismatch│└   stack.push('5')               ~~~Detected 1 problem from 1 file

Types with Sorbet

Sorbet was created 9 years ago, and it is developed by Stripe. Unlike with RBS with Sorbet you will usually add the types directly into the Ruby code. You can also start by adding types gradually by adding #typed: false/true at the top of a Ruby file.

To install Sorbet add this to your Gemfile:

gem 'sorbet', :group => :developmentgem 'sorbet-runtime'gem 'sorbet-static-and-runtime'gem 'tapioca', require: false, :group => [:development, :test]

Then execute bundle install and srb init.

MinStack Example

We can reuse the MinStack code from the previous example and as you can see it already has added types:

# typed: true# frozen_string_literal: truerequire 'sorbet-runtime' # You must require this gemclass MinStack  extend T::Sig # Enables the 'sig' syntax  def initialize    # We explicitly tell Sorbet these are arrays of Integers    @stack = T.let([], T::Array[Integer])    @min_stack = T.let([], T::Array[Integer])  end  sig { params(value: Integer).void }  def push(value)    # If min_stack is empty, the new value is the min.    # Otherwise, compare the new value with the current min.    # We use T.must because we checked .empty?, so .last is technically safe,    # but Sorbet needs reassurance for the array logic.    current_min = @min_stack.last    minimum_value = current_min.nil? ? value : [value, current_min].min    @stack << value    @min_stack << minimum_value  end  # Returns Integer or nil (if stack is empty)  sig { returns(T.nilable(Integer)) }  def top    @stack.last  end  # Removes the top item and returns it (or nil)  sig { returns(T.nilable(Integer)) }  def pop    @min_stack.pop    @stack.pop  end  sig { returns(T.nilable(Integer)) }  def get_minimum    @min_stack.last  endenddef main  puts 'Hello world'  stack = MinStack.new  stack.push(5)  stack.push(-10)  stack.push(-1)  stack.push(20)  stack.pop  # We use safe navigation (&.) because top/get_minimum can return nil  puts "Top is #{stack.top&.to_s}"  puts "Min is #{stack.get_minimum&.to_s}"endmain if __FILE__ == $PROGRAM_NAME

The nice thing about using Sorbet is that all the types are in the same file.

Static Type Checking

You can statically type check the Ruby code by executing srb check. This would be useful to add in a CI pipeline or as a pre-commit git hook.

You’ll see that there are no errors:

srb tc  No errors! Great job.

And if you modify the push again: stack.push('5') you will get an error when you run srb tc:

srb tclib/minstack.rb:52: Expected Integer but found String("5") for argument value https://srb.help/7002    52 |  stack.push('5')                     ^^^  Expected Integer for argument value of method MinStack#push:    lib/minstack.rb:15:    15 |  sig { params(value: Integer).void }                       ^^^^^  Got String("5") originating from:    lib/minstack.rb:52:    52 |  stack.push('5')                     ^^^Errors: 1

Conclusion

We learned about RBS and Sorbet and how to use them by exploring a simple code example.

RBS ships with Ruby 3.0 and doesn’t offer runtime validation by default. You need to write the types in a separate .rbs file and this is great if you’re writing a custom gem, because you don’t want to force dependencies on your users.

Sorbet is created by Stripe, and it is also used in production by them and other companies. You write the types directly in the Ruby files anb you can turn it off for certain files. I would use it if I was developing and application, either an API, CLI or some other app.

Thank you for reading!

References