ruby

RBS: A New Ruby 3 Typing Language in Action

Diogo Souza on

The long-awaited version 3.0.0 of Ruby has finally been released. Along with many great improvements, such as a 3x faster performance boost compared to the previous version, concurrency-parallel experimental features, etc., the Ruby team also introduced a new syntax language for dynamic typing in Ruby: RBS.

That was something the team had been discussing for years, based on the success of community-developed tools for static type checking such as Sorbet.

Sorbet is a powerful type checker backed by Stripe. It checks your code by both annotating and/or defining RBI files. RBI files, in turn, work as interfaces between static and dynamic components providing a "description" of them (constants, ancestors, metaprogramming code, and more).

So, if Sorbet mostly deals with static checking and RBS was made to address dynamic typing, what's the difference between them? How will they both coexist? When should I use one instead of the other?

Those are fairly common questions about the major role of RBS. That's why we decided to write this piece. To clarify, in practice, why you should consider adopting it based on what it's capable of. Let's dive right in!

Starting with the Basics

Let's start with a clear understanding of the difference between static typing _and dynamic typing_. Although it's basic, it is a key concept to grasp in order to understand the role of RBS.

Let's take a code snippet from a statically typed language as a reference:

➜
String str = "";
str = 2.4;

It's no news that such a language cares for the types of its objects and variables. That being said, code like the one above will throw an error.

Ruby, like many other languages such as JavaScript, Python, and Objective-C, doesn't give that much attention to which types you're targeting for your objects.

The same code in Ruby will just successfully run, as seen below:

➜  irb
str = ""
str = 2.4
puts str # prints 2.4

This is possible because Ruby's interpreter knows how to dynamically switch from one type to another.

However, there's a limit to what the interpreter allows. For example, take the following code change:

➜  irb
val = "6.0"
result = val + 2.0
puts result

This will, in turn, produce the following error:

Error: no implicit conversion of Float into String

Running the same code with JavaScript, for instance, would run just fine.

Moral of the story: Ruby does infer types dynamically, indeed; but, unlike other major dynamic languages, it won't accept everything. Be aware of that!

And that's where type checkers (whether static or dynamic) become useful.

RBS vs Sorbet

Right, I've got your point about the dynamic vs static thing. But, what about Sorbet? Will it get deprecated?

Not at all. The primary (and perhaps the most important) difference between RBS and Sorbet is that the former is just a language, while the latter is a complete type checker itself.

The Ruby team asserts RBS' main goal as to describe the structure of your code. It won't perform the type checking, but rather, define the structure that type checkers (like Sorbet or any other) can use to, well, type check. The code structure is stored within a new file extension — .rbs.

To check it out, let's take the following Ruby class as an example:

class Super
def initialize(val)
@val = val
end

def val?
@val
end
end

class Test < Super
def initialize(val, flag)
super(val)
@flag = flag
end

def flag?
@flag
end
end

It represents a simple inheritance in Ruby. The interesting thing to note here is that we can't guess the types of each attribute used in the class, except for flag.

Since flag comes initialized with a default value, both the developer and a type checker can infer the type to prevent further misuse.

The following will be a proper representation of the above class in RBS format:

class Super

def initialize : (val: untyped) -> void
end

class Test < Super

def initialize : (val: untyped, ?flag: bool) -> void
def flag? : () -> bool
end

Take some time to digest this. It's a declaration language, so only signatures may appear in an RBS file. Simple, isn’t it?

Whether it was autogenerated by a CLI tool (more on that later) or by you, it's safer to annotate a type as untyped when it can't be guessed.

If you're sure about the type of val, for example, your RBS mapping could be switched to the following:

class Super

def initialize : (val: Integer) -> void
end

It's also important to note that both the Ruby and Sorbet teams were working (and still are) towards the creation and improvement of RBS. It was the Sorbet team's experience with type checking, for years, that helped the Ruby team to fine-tune a lot of stuff with this project.

The interoperability between RBS and RBI files is still under development. The goal is for Sorbet and any other checker tools to have an official and centralized basis to follow.

The RBS CLI Tool

One important consideration the Ruby team had when developing RBS was to ship a CLI tool that could help developers to try it out and learn how to use it. It's called rbs and comes by default with Ruby 3. If you still haven't upgraded your Ruby version, you can add its gem directly to your project, as well:

➜  gem install rbs


The command rbs help will show the command usage along with the available commands.

List of available commands

Most of these commands focus on parsing and analyzing the Ruby code structures. For example, the command ancestors sweeps the hierarchical structure of a given class to check for its ancestors:

➜  rbs ancestors ::String
::String
::Comparable
::Object
::Kernel
::BasicObject

The command methods displays all the method structures of a given class:

➜  rbs methods ::String
! (public)
!= (public)
!~ (public)
...
Array (private)
Complex (private)
Float (private)
...
b (public)
between? (public)
...

Want to see a specific method structure? Go for method:

➜  rbs method ::String split
::String#split
defined_in: ::String
implementation: ::String
accessibility: public
types:
(?::Regexp | ::string pattern, ?::int limit) -> ::Array[::String]
| (?::Regexp | ::string pattern, ?::int limit) { (::String) -> void } -> self

For those starting with RBS today, the command prototype can help a lot with scaffolding types for classes that already exist. The command generates prototypes of RBS files.

Let's take the previous Test < Super inheritance example and save the code into a file called appsignal.rb. Then, run the following command:

➜  rbs prototype rb appsignal.rb


Since the command allows for rb, rbi, and runtime generators, you need to provide the specific type of file you're scaffolding right after the prototype command, followed by the file pathname.

The following is the result of the execution:

class Super
def initialize: (untyped val) -> untyped

def val?: () -> untyped
end

class Test < Super
def initialize: (untyped val, ?flag: bool flag) -> untyped

def flag?: () -> untyped
end

Pretty similar to our first RBS version. As mentioned earlier, the tool marks as untyped any type that couldn't be guessed.

It also counts for method returns. Notice the return type of the flag definition. As a developer, you're probably sure that the method always returns a boolean, but due to Ruby’s dynamic nature, the tool is unable to 100% say that it is so.

And that's when another Ruby 3 child comes to the rescue: the TypeProf.

The TypeProf Tool

TypeProf is a type analysis tool for Ruby that was created on top of some syntax tree interpretation.

Despite still being experimental, it has proved to be very powerful when it comes to understanding what your code is trying to do.

If you don't have Ruby 3 yet, simply add the gem to your project:

➜  gem install typeprof


Now, let's run the same appsignal.rb file against it:

➜  typeprof appsignal.rb


This is the output:

# Classes
class Super
@val: untyped
def initialize: (untyped) -> untyped
def val?: -> untyped
end

class Test < Super
@val: untyped
@flag: true

def initialize: (untyped, ?flag: true) -> true
def flag?: -> true
end

Note how the flag is mapped now. This is only possible because, unlike what the RBS prototype does, the TypeProf scans the method's body trying to understand what actions are being performed over that specific variable. Since it couldn't identify any direct change to this variable, TypeProf safely mapped the method return as a boolean.

Consider, for example, that TypeProf will have access to other classes that instantiate and use the Test class. With that in hand, it can go even deeper into your code and fine-tune its predictions. Say that the following code snippet is added at the end of the appsignal.rb file:

testSub = Test.new("My value", "My value" == "")
testSup = Super.new("My value")

And that you changed the initialize method signature to the following:

def initialize(val, flag)

When you re-run the command, this should be the output:

# Classes
class Super
@val: String

def initialize: (String) -> String
def val?: -> String
end

class Test < Super
@val: String
@flag: bool

def initialize: (String val, bool flag) -> bool
def flag?: -> bool
end

Super cool!

TypeProf can't deal with inherited attributes very well. That's why we're instantiating a new Super object. Otherwise, it wouldn't get that val is a String.

The major pro of TypeProf is its safety. Whenever it's unable to figure something out for sure, then untyped will be returned.

Partial RBS Specification

One important warning from the official docs states that, although TypeProf is very powerful, you should be aware of its limitations regarding what it can and cannot generate in terms of RBS code.

For example, a common practice among Ruby developers is method overloading in which you invoke different behavior of a method depending on its arguments.

Consider that a new method spell is added to the Super class, which returns an Integer or a String based on the parameter type:

def spell(val)
if val.is_a?(String)
""
else
0
end
end

RBS embraces this practice by allowing you to deal with overloading through the union type (a value that represents multiple possible types) syntax:

def spell: (String) -> String | (Integer) -> Integer

TypeProf can't infer this just by analyzing the method's body. To help it out, you can manually add such a definition to your RBS file and TypeProf will always check there first for instructions.

For this, you must add the RBS file path at the end of the command:

typeprof appsignal.rb appsignal.rbs

See below the new output:

class Super
...
def spell: (untyped val) -> (Integer | String)
end

Plus, we can also verify the real types during runtime via Kernel#p to test if the overloading is working by adding the next two lines to the end of the appsignal.rb file:

p testSup.spell(42)
p testSup.spell("str")

This should be the output:

# Revealed types
#  appsignal.rb:11 #=> Integer
#  appsignal.rb:12 #=> String

...

Make sure to refer to the official docs for more information, especially the section concerning TypeProf limitations.

Duck Typing

You've heard of that before. If a Ruby object does everything a duck does, then it is a duck.

As we've seen, Ruby doesn't care about what your objects are meant to be. Types can change dynamically as well as object references.

Although helpful, duck typing can be tricky. Let's see an example.

Suppose that, from now on, the val attribute you've declared for the Super class, which is a String, must always be convertible to an integer.

Rather than trusting that developers will always guarantee the conversion (perhaps, throwing an error otherwise), you can create an interface stating that:

interface _IntegerConvertible
def to_int: () -> Integer
end

Interface types provide one or more methods that are detached from concrete classes and modules. This way, when you want a certain type to be passed on to the Super instantiation, you can simply do the following:

class Super

def initialize : (val: _IntegerConvertible) -> void
end

The concrete class or module that implements this interface will have to make sure the proper validation is done.

Metaprogramming

Perhaps one of the most dynamic features of Ruby is the capability of creating code that creates code by itself during runtime. That's metaprogramming.

Because of the uncertain nature of things, the RBS CLI tool isn't able to generate RBS out of metaprogramming code.

Let's take the following snippet as an example:

class Test
define_method :multiply do |*args|
args.inject(1, :*)
end
end

p Test.new.multiply(2, 3, 5)

This class defines a method called multiply at runtime and instructs it to inject the arguments and multiply each one with the previous result.

Once you run the RBS prototype command, this should be the output:

class Test
end

Depending on the complexity of your metaprogramming code, TypeProf will still try its best to extract something from it. But it's not always guaranteed.

Remember, you can always add your own type mappings to the RBS file and TypeProf will obey them in advance. That's also valid for metaprogramming.

It's also important to keep updated with the latest repository changes since the team is constantly releasing new features, which may well include updates on metaprogramming.

That being said, if your codebase includes some type of metaprogramming, be careful with these tools. Don't use them blindly!

Wrapping Up

There are plenty more details about what we've discussed so far, as well as edge use cases for both RBS and TypeProf that you should be aware of.

So, make sure to refer to the official docs for more on that.

RBS is still so fresh but has already caused a huge impact on Rubyists that are used to type checking their codebases with other tools.

What about you? Have you tried it out? What are your thoughts on RBS?

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!