DEV Community

Cover image for An introduction to types in Ruby
Nucu Labs
Nucu Labs

Posted on

An introduction to types in Ruby

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

MinStack Example

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

# frozen_string_literal: true

class 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
  end
end

def 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}"
end


main if __FILE__ == $PROGRAM_NAME
Enter fullscreen mode Exit fullscreen mode

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?
end

class Object
  private def main: () -> void
end
Enter fullscreen mode Exit fullscreen mode

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: () -> void
end
Enter fullscreen mode Exit fullscreen mode

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 steep
steep init
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

➜  steep check 
# Type checking files:
..
No type error detected. 🫖
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 => :development
gem 'sorbet-runtime'
gem 'sorbet-static-and-runtime'
gem 'tapioca', require: false, :group => [:development, :test]
Enter fullscreen mode Exit fullscreen mode

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

require 'sorbet-runtime' # You must require this gem

class 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
  end
end

def 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}"
end

main if __FILE__ == $PROGRAM_NAME
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

srb tc
lib/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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)