Understanding RBS, Ruby's new Type Annotation System

This article was originally written by Julio Sampaio on the Honeybadger Developer Blog.

class MyClass
  def my_method : (my_param: String) -> String
end

By providing type annotations with RBS you get benefits such as:

  • a clean and concise way to define the structure of your codebase.
  • a safer way to add types to your legacy code via files rather than changing the classes directly.
  • the potential to universally integrate with static and dynamic type checkers.
  • new features to deal with method overloading, duck typing, dynamic interfaces, and more.

But wait! Aren't there already static type checkers like Sorbet and Steep?

Yes, and they're great! However, after four years of discussion and a handful of community-built type checkers, the Ruby committer team thought it was time to define some standards for building type checker tools.

RBS is officially a language, and it's coming to life along with Ruby 3.

Plus, due to Ruby's dynamically typed nature, as well as common patterns like Duck Typing and method overloading, some precautions are being instituted to ensure the best approach possible, which we'll see in more detail shortly.

Ruby 3 Install

To follow the examples shown here, we need to install Ruby 3.

You can do it either by following the official instructions or via ruby-build in case you need to manage many Ruby versions.

Alternatively, you can also install the rbs gem directly into your current project:

gem install rbs

Static vs Dynamic Typing

Before going any further, let's clarify this concept. How do dynamically typed languages compare to static ones?

In dynamically typed languages, such as Ruby and JavaScript, there are no predefined data types for the interpreter to understand how to proceed in case a forbidden operation happens during runtime.

That is the opposite of what's expected from static typing. Statically typed languages, such as Java and C, verify the types during compile time.

Take the following Java code snippet as a reference:

int number = 0;
number = "Hi, number!";

This is not possible in static typing, since the second line will throw an error:

error: incompatible types: String cannot be converted to int

Now, take the same example in Ruby:

number = 0;
number = "Hi, number!";
puts number // Successfully prints "Hi, number!"

In Ruby, the type of a variable varies on the fly, which means that the interpreter knows how to dynamically swap from one to another.

That concept is commonly confused with strongly typed vs weakly typed languages.

Ruby is not only a dynamically but also strongly typed language, which means that it allows for a variable to change its type during runtime. It does not, however, allow you to perform crazy type mixing operations.

Take the next example (in Ruby) adapted from the previous one:

number = 2;
sum = "2" + 2;
puts sum

This time we're trying a sum of two numbers that belong to different types (an Integer and a String). Ruby will throw the following error:

main.rb:2:in `+': no implicit conversion of Integer into String (TypeError)
  from main.rb:2:in `<main>'

In other words, Ruby's saying that the job for performing complex calculations involving (possibly) different types is all yours.

JavaScript, on the other hand, which is weakly and dynamically typed, allows the same code with a different result:

> number = 2
  sum = "2" + 2
  console.log(sum)
> 22 // it concatenates both values

How does RBS differ from Sorbet?

First, from the approach that each one takes to annotating code. While Sorbet works by explicitly adding annotations throughout your code, RBS simply requires the creation of a new file with the .rbs extension.

The primary advantage of this is when we think about the migration of legacy codebases. Since your original files won't get affected, it's much safer to adopt RBS files into your projects.

According to its creators, RBS’ main goal is to describe the structure of your Ruby programs. It focuses on defining class/method signatures only.

RBS itself can't type check. Its goal is restricted to defining the structure as a basis for type checkers (like Sorbet and Steep) to do their job.

Let's see an example of a simple Ruby inheritance:

class Badger
    def initialize(brand)
      @brand = brand
    end

    def brand?
      @brand
    end
end

class Honey < Badger
  def initialize(brand: "Honeybadger", sweet: true)
    super(brand)
    @sweet = sweet
  end

  def sweet?
    @sweet
  end
end

Great! Just two classes with a few attributes and inferred types.

Below, we can see a possible RBS representation:

class Brand
  attr_reader brand : String

  def initialize : (brand: String) -> void
end

class Honey < Brand
  @sweet : bool

  def initialize : (brand: String, ?sweet: bool) -> void
  def sweet? : () -> bool
end

Pretty similar, aren't they? The major difference here is the types. The initialize method of the Honey class, for example, receives one String and one boolean parameter and returns nothing.

Meanwhile, the Sorbet team is working closely on the creation of tools to allow interoperability between RBI (Sorbet's default extension for type definition) and RBS.

The goal is to lay the groundwork for Sorbet and any type checker to understand how to make use of RBS' type definition files.

Scaffolding Tool

If you're starting with the language and already have some projects going on, it could be hard to guess where and how to start typing things around.

With this in mind, the Ruby team provided us with a very helpful CLI tool called rbs to scaffold types for existing classes.

To list the available commands, simply type rbs help in the console and check the result:

Perhaps the most important command in the list is prototype since it analyzes ASTs of the source code file provided as a param to generate "approximate" RBS code.

Approximate because it is not 100% effective. Since your legacy code is primarily untyped, most of its scaffolded content will come the same way. RBS can't guess some types if there are no explicit assignments, for example.

Let's take another example as reference, this time involving three different classes in a cascaded inheritance:

class Animal
    def initialize(weight)
      @weight = weight
    end

    def breathe
      puts "Inhale/Exhale"
    end
end

class Mammal < Animal
    def initialize(weight, is_terrestrial)
      super(weight)
      @is_terrestrial = is_terrestrial
    end

    def nurse
      puts "I'm breastfeeding"
    end
end

class Cat < Mammal
    def initialize(weight, n_of_lives, is_terrestrial: true)
        super(weight, is_terrestrial)
        @n_of_lives = n_of_lives
    end

    def speak
        puts "Meow"
    end
end

Just simple classes with attributes and methods. Note that one of them is provided with a default boolean value, which will be important to demonstrate what RBS is capable of when guessing by itself.

Now, to scaffold these types, let's run the following command:

rbs prototype rb animal.rb mammal.rb cat.rb

You can pass as many Ruby files as you wish. The following is the result of this execution:

class Animal
  def initialize: (untyped weight) -> untyped

  def breathe: () -> untyped
end

class Mammal < Animal
  def initialize: (untyped weight, untyped is_terrestrial) -> untyped

  def nurse: () -> untyped
end

class Cat < Mammal
  def initialize: (untyped weight, untyped n_of_lives, ?is_terrestrial: bool is_terrestrial) -> untyped

  def speak: () -> untyped
end

As we've predicted, RBS can't understand most of the types we were aiming for when we created the classes.

Most of your job will be to manually change the untyped references to the real ones. Some discussions aiming to find better ways to accomplish this are happening right now in the community .

Metaprogramming

When it comes to metaprogramming, the rbs tool won't be of much help due to its dynamic nature.

Take the following class as an example:

class Meta
    define_method :greeting, -> { puts 'Hi there!' }
end

Meta.new.greeting

The following will be the result of scaffolding this type:

class Meta
end

Duck Typing

Ruby doesn't worry too much about objects’ natures (their types) but does care about what they're capable of (what they do).

Duck typing is a famous programming style that operates according to the motto:

"If an object behaves like a duck (speak, walk, fly, etc.), then it is a duck"

In other words, Ruby will always treat it like a duck even though its original definition and types were not supposed to represent a duck.

However, duck typing can hide details of the code implementation that can easily become tricky and difficult to find/read.

RBS introduced the concept of interface types, which are a set of methods that do not depend on any concrete class or module.

Let's take the previous animal inheritance example and suppose that we're adding a new hierarchical level for terrestrial animals from which our Cat will inherit to:

class Terrestrial < Animal
    def initialize(weight)
        super(weight)
    end

    def run
        puts "Running..."
    end
end

To avoid non-terrestrial children objects from running, we can create an interface that checks for the specific type for such an action:

interface _CanRun
  # Requires `<<` operator which accepts `Terrestrial` object.
  def <<: (Terrestrial) -> void
end

When mapping the RBS code to the specific run method, that'd be the signature:

def run: (_CanRun) -> void

Whenever someone tries to pass anything other than a Terrestrial object to the method, the type checker will make sure to log the error.

Union Types

It's also common among Rubyists to have expressions that hold different types of values.

def fly: () -> (Mammal | Bird | Insect)

RBS accommodates union types by simply joining them via pipe operator.

Method Overloading

Another common practice (among many programming languages actually) is to allow for method overloading, in which a class can have more than one method with the same name but the signature is different (like the types or number of params, their order, etc.).

Let's take an example in which an animal can return its closest evolutionary cousins:

def evolutionary_cousins: () -> Enumerator[Animal, void] | { (Animal) -> void } -> void

In this way, RBS allows us to explicitly determine whether a given animal will have a single evolutionary cousin or a bunch of them.

TypeProf

In parallel, the Ruby team has also started a new project called typeprof, an experimental type-level Ruby interpreter that aims to analyze and (try to) generate RBS content.

It works by abstract interpretation and is still taking the first steps towards better refinement, so be careful when using it for production purposes.

To install it, simply add the gem to your project:

gem install typeprof

Be aware that it requires a Ruby version greater than 2.7.

Take the following version of the Animal class:

class Animal
    def initialize(weight)
      @weight = weight
    end

    def die(age)
      if age > 50
        true
      elsif age <= 50
        false
      elsif age < 0
        nil
      end
    end
end

Animal.new(100).die(65)

Based on what's going on inside the method age and the further call to the same method, TypeProf can smartly infer the types manipulated in the code.

When you run the typeprof animal.rb command, that should be the output:

## Classes
class Animal
  @weight: Integer

  def initialize: (Integer weight) -> Integer
  def die: (Integer age) -> bool?
end

It's a powerful tool that has a lot to offer for projects that already have lots of code going on.

VS Code Integration

Currently, there are not a lot of available VS Code plugins for dealing with RBS for formatting, structure checking, etc. Especially because it's still relatively new.

However, if you search in the store for "RBS" you may find one plugin called ruby-signature which will help with syntax highlighting, as shown below:

Conclusion

RBS is just so fresh and already represents an important step towards safer Ruby codebases.

Normally, in time new tools and open-source projects will rise to back it up, such as the RBS Rails for generating RBS files for Ruby on Rails applications.

The future holds amazing stuff for the Ruby community with safer and more bug-free applications. Can't wait to see it!

About Honeybadger

46