Static type checking in Ruby! Introduction to RBS with sample code - Major new features and changes in Ruby 3.0 Part 1

Mar 17, 2021

This article was originally written by @jnichiro in Japanese and translated into English by me (@suusan2go). You can find the original article here. Rubyで型チェック!動かして理解するRBS入門 〜サンプルコードでわかる!Ruby 3.0の主な新機能と変更点 Part 1〜

The original article was written before the official release of Ruby 3.0 so he used Ruby 3.0.0dev.

Introduction

Ruby 3.0 introduces a mechanism called RBS that provides type definition information to Ruby code. This article explains the usage and roles of RBS and its surrounding tools through a simple sample program.

Versions of software used in this article

Ruby 3.0 has not yet been officially released at the time of writing this article. The actual behavior may differ from this article after the official release or future version upgrades. In this article, I used these versions.

  • Ruby 3.0.0dev (2020-11-13T16:46:08Z master 7826210541) [x86_64-darwin19]
  • RBS 0.17.0
  • TypeProf 0.4.2
  • Steep 0.36.0 Since these gems (RBS, TypeProf, and Steep) provide type-checking features, you can also try these features in Ruby 2.7 by installing these gems. I'll explain the details later.

Sample code

You can find the sample code used in this article in this repository.

Adding type definitions to your code

Let's create your own simple Ruby class and define the type to see how Ruby type checking works. You can try Ruby 3.0 type checking on your own by following steps.

First, create a directory for our experiment. In this case, create a directory called rbs-sandbox.

$ mkdir rbs-sandbox $ cd rbs-sandbox

And then create a lib directory and create fizz_buzz.rb in it.

$ mkdir lib $ touch lib/fizz_buzz.rb

Next, Write the following code in lib/fizz_buzz.rb,

class FizzBuzz def self.run(n) 1.upto(n).map do |n| if n % 15 == 0 'FizzBuzz' elsif n % 3 == 0 'Fizz' elsif n % 5 == 0 'Buzz' else n.to_s end end end end

We can write your type definitions for the FizzBuzz.run method manually, but this time let's use the TypeProf command that comes with Ruby 3.0 to generate the type definitions automatically.

To automatically generate type definitions with TypeProf, we need Ruby code to execute the target code.

So let's write the following code in runner/fizz_buzz_runner.rb. (Note: The directory name "runner" is a random name, not a reserved word for RBS or anything. )

$ mkdir runner $ touch runner/fizz_buzz_runner.rb
require_relative '../lib/fizz_buzz' results = FizzBuzz.run(15) puts results

To be sure, let's also check that the above code works. If you get a result like the following, you are good to go.

$ ruby runner/fizz_buzz_runner.rb 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz

Now we are ready to run TypeProf. Enter the following command, and you will get the following output. (Note: At the time of writing this article, a large number of strange warnings are also output: unknown RBS type: RBS::Types::Bases::Top)

$ typeprof runner/fizz_buzz_runner.rb # Classes class FizzBuzz def self.run : (Integer) -> Array[String] end

The output result is the type definition of the FizzBuzz class, which is automatically type-inferred by TypeProf. As shown in (Integer) -> Array[String], it expresses that passing an argument of type Integer will return an Array of String as the return value.

Next, let's use a gem called Steep to try static type checking on our program. Ruby 3.0 doesn't include Steep, so we need to install it manually.

$ gem install steep

After installing the gem, run the steep init command.

$ steep init Writing Steepfile...

This command will create a Steepfile like the below.

# target :lib do # signature "sig" # # check "lib" # Directory name # check "Gemfile" # File name # check "app/models/**/*.rb" # Glob # # ignore "lib/templates/*.rb" # # # library "pathname", "set" # Standard libraries # # library "strong_json" # Gems # end # target :spec do # signature "sig", "sig-private" # # check "spec" # # # library "pathname", "set" # Standard libraries # # library "rspec" # end

The Steepfile is a configuration file that Steep requires. At first, the example configurations are commented out. Let's rewrite this Steepfile as follows.

target :lib do check "lib" check "runner" signature "sig" end

The above configuration means that the lib and runner directories are for type checking, and the sig directory is for type definitions. (Note: At the time of writing this article, I could not find any explanation about DSL in Steepfile, so I guessed the meaning of the setting)

Let's add the type definition file in the sig directory. Make a sig directory and fizz_buzz.rbs file in it. Then, write the type definitions from TypeProf into fizz_buzz.rbs.

class FizzBuzz def self.run : (Integer) -> Array[String] end

You are now ready to use Steep. Now, enter the command "steep check".

$ steep check

Did anything happen? I believe nothing is happening. If nothing happens, it means that the type check was successful. To see if Steep can detect an invalid type, change fizz_buzz_runner.rb as follows.

require_relative '../lib/fizz_buzz' -results = FizzBuzz.run(15) +results = FizzBuzz.run('abc') puts results

You should get an error when checking the type because you passed String instead of Integer as the argument.

$ steep check runner/fizz_buzz_runner.rb:3:23: ArgumentTypeMismatch: receiver=singleton(::FizzBuzz), expected=::Integer, actual=::String ('abc')

After checking this error, please undo the code in fizz_buzz_runner.rb.

Steep also checks the type of Ruby's built-in libraries. To confirm this, let's call the "upto" method on a string in fizz_buzz.rb as follows.

class FizzBuzz def self.run(n) - 1.upto(n).map do |n| + '1'.upto(n).map do |n| if n % 15 == 0

You should get an error from the type checking. (Note: At the time of writing this, the same error message is printed twice for some reason.)

$ steep check lib/fizz_buzz.rb:3:4: UnresolvedOverloading: receiver=::String, method_name=upto, method_types=(::string, ?::boolish) -> ::Enumerator[::String, ::String] | (::string, ?::boolish) { (::String) -> void } -> ::String ('1'.upto(n))

After checking this error, please undo the code in fizz_buzz_runner.rb.

Summary of basic type checking in Ruby 3.0

  • You can define a type information in a file called RBS (You can't write type information in the Ruby script itself).
  • You can write RBS by yourself, but you can use TypeProf to automatically generate RBS by type inference (but you need to write code to run).
  • Type checking is not a function of Ruby itself but is a function of external gems. Steep is one of them.
  • Ruby's built-in libraries provide type information by default so that we can check types of them.

Type checking for the standard library

In this article, I'll call a class that we can use without require, such as String and Integer, "built-in libraries" (or core libraries), and a class that requires "require", such as Date and Pathname classes, "standard libraries".

In this section, I'll explain the procedure to run type-checking for standard libraries.

At first, let's change the "fizz_buzz_runner.rb" to the following. In this case, we won't use the fixed number like 15 but will use the current day (1 to 31) to repeat the FizzBuzz function.

+require 'date' require_relative '../lib/fizz_buzz' -results = FizzBuzz.run(15) +results = FizzBuzz.run(Date.today.day) puts results

Then, let's run Steep. There is nothing wrong with the code, so nothing happens. Now let's change .day to .dy.

require 'date' require_relative '../lib/fizz_buzz' -results = FizzBuzz.run(Date.today.day) +results = FizzBuzz.run(Date.today.dy) puts results

#dy doesn't exist in Date class, so you should get an error.

$ steep check

oops? Nothing happened again. Actually, to check the type of standard library, it's necessary to specify its name in the Steepfile. So let's add the following line to the Steepfile.

target :lib do check "lib" check "runner" signature "sig" + library "date" end

Now steep can detect type errors like this.

$ steep check runner/fizz_buzz_runner.rb:4:23: NoMethodError: type=::Date, method=dy (Date.today.dy)

Type checking for gems

When Ruby 3.0 is released, Ruby will include type definition information from the beginning for built-in libraries and standard libraries. However for the other libraries like third party gems, we need additional type definitions.

Just like DefinitelyTyped in TypeScript, RBS has a repository called .

When writing this article, only a few gem types, such as listen, rainbow, Redis, and retryable in the gem_rbs_collection. But as RBS becomes more popular, the number of type information will increase. (Of course, you can contribute!)

Let's try to use the Retriable gem's type definition. First, install the Retriable gem.

$ gem install retryable

Next, rewrite fizz_buzz_runner.rb as follows. (This code itself doesn't make much sense because we just want to check the type of Retriable. )

require 'date' require 'retryable' require_relative '../lib/fizz_buzz' Retryable.retryable(tries: 3) do results = FizzBuzz.run(Date.today.day) puts results end

Then, use the git submodule to import gem_rbs_collection.

$ git init
$ git submodule add https://github.com/ruby/gem_rbs.git vendor/rbs/gem_rbs

Edit Steepfile as follows to load type definition for Retryable. We have to add library "forwardable" because Retryable is using the Forwardable module internally. If we don't add this line, Steep will return errors like UnknownTypeNameError: name=Forwardable when running steep check.

target :lib do check "lib" check "runner" signature "sig" + repo_path "vendor/rbs/gem_rbs/gems" + library "retryable" + library "forwardable" end

If we run steep check this time, nothing should happen because there are no type errors.

$ steep check

Then, Let's rewrite .retryable to .retryablee.

require 'date' require 'retryable' require_relative '../lib/fizz_buzz' -Retryable.retryable(tries: 3) do +Retryable.retryablee(tries: 3) do results = FizzBuzz.run(Date.today.day) puts results end

You can see an error like the below because Retryable doesn't have a method like retryablee.

$ steep check runner/fizz_buzz_runner.rb:5:0: NoMethodError: type=singleton(::Retryable), method=retryablee (Retryable.retryablee(tries: 3) do)

Steep will check keyword arguments as well. Let's change 3 to the string "3".

require 'date' require 'retryable' require_relative '../lib/fizz_buzz' -Retryable.retryable(tries: 3) do +Retryable.retryable(tries: "3") do results = FizzBuzz.run(Date.today.day) puts results end

steep check will return an error and tell us the type of keyword argument is wrong.

$ steep check runner/fizz_buzz_runner.rb:5:20: ArgumentTypeMismatch: receiver=singleton(::Retryable), expected=::Retryable::Configuration::options, actual={ :tries => ::String } (tries: "3")

Work with VSCode extension

Steep provides a VSCode extension.

You can find this extension by searching "Steep". Let's install this.

You need to set up bundler to use this extension. Let's setup Gemfile by running bundle init.

$ bundle init

Then edit Gemfile and run bundle install.

# frozen_string_literal: true source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "steep" gem "retryable"

Open our rbs-sandbox directory by VSCode, and now you can see type errors on VSCode!

The autocomplete function for method names also works (VSCode displays type information at the same time).

If you hover the cursor over a method, VSCode will also display the type information for that method.

Use TypeProf and RBS files to get the diffs in type information

As already explained, TypeProf can be used to generate type information automatically. However, since TypeProf guesses the type from the code, sometimes the type information may not same as intended by the programmer.

For example, let's check the following code.

# lib/person.rb class Person def initialize(name) @name = name end end
# runner/person_runner.rb require_relative '../lib/person' Person.new('Alice')

In this case, TypeProf will generate the type information as follows.

$ typeprof runner/person_runner.rb # Classes class Person @name : String def initialize : (String) -> String end

The initialize method return String type, but most of Ruby programmers intended to "just set the name to the instance variable" not to return the String value.

In this case, you need to edit the output result of TypeProf yourself. You can change the return value of sig/person.rbs from String to void and saved it as follows.

class Person @name : String def initialize : (String) -> void end

Next, implement the hello method in the Person class and call it in person_runner.rb

class Person def initialize(name) @name = name end + def hello + puts "Hello, I'm #{@name}!" + end end
require_relative '../lib/person' person = Person.new('Alice') person.hello

If you rerun TypeProf in this state, the return type of initialize method will be overrided to String again. It is troublesome to modify the previously modified content over and over again every time you run TypeProf.

To avoid this problem, TypeProf allows you to use existing rbs files as input for type inference.

Let's run TypeProf with the person.rbs file we just created as the argument. In this way, only the type information that we don't define in the existing RBS file will be output (TypeProf will comment out current type information).

$ typeprof sig/person.rbs runner/person_runner.rb # Classes class Person # @name : String # def initialize : (String) -> void def hello : -> nil end

Also, TypeProf can output the automatically generated type information to a file by adding the -o option.

$ typeprof sig/person.rbs runner/person_runner.rb -o sig/person.gen.rbs

Create a template for a type definition file with the rbs command

So far, we have used the TypeProf command to create a type definition file, but instead of TypeProf, we can use the rbs command to generate a type definition file prototype.

$ rbs prototype rb lib/fizz_buzz.rb class FizzBuzz def self.run: (untyped n) -> untyped end

When using the rbs command, RBS doesn't infer type like TypeProf (both arguments and return value are output as untyped). Instead, you do not need to prepare a program (runner script in this article) to execute the target file.

Checking method signatures with the rbs command

The rbs command has several other functions besides creating a skeleton. The following is an example of using the rbs command to check method signatures.

# Check the signature of String#rjust $ rbs method String rjust ::String#rjust defined_in: ::String implementation: ::String accessibility: public types: (::int integer, ?::string padstr) -> ::String # Check the signature of Math.sin $ rbs method --singleton Math sin ::Math.sin defined_in: ::Math implementation: ::Math accessibility: public types: (::Numeric x) -> ::Float # Check the signature of Date#month(a Standard Library needs require) $ rbs -r date method Date month ::Date#month defined_in: ::Date implementation: ::Date accessibility: public types: () -> ::Integer # Check the signature of Retryable.retryable(in gem, class method) $ rbs --repo vendor/rbs/gem_rbs/gems -r retryable -r forwardable method --singleton Retryable retryable ::Retryable.retryable defined_in: ::Retryable implementation: ::Retryable accessibility: public types: [X] (?::Retryable::Configuration::options options) ?{ (::Integer, ::Exception?) -> X } -> X?

Generate RBS from test code

In this article, I created a script such as fizz_buzz_runner.rb, but you can also use a test code such as Minitest to generate RBS.

require 'minitest/autorun' require_relative '../lib/fizz_buzz' class RubyTest < Minitest::Test def test_run expected = ['1', '2', 'Fizz', '4', 'Buzz', 'Fizz', '7', '8', 'Fizz', 'Buzz', '11', 'Fizz', '13', '14', 'FizzBuzz'] assert_equal expected, FizzBuzz.run(15) end end
$ typeprof test/fizz_buzz_test.rb # Classes class FizzBuzz def self.run : (Integer) -> Array[String] end class FizzBuzzTest def test_run : -> untyped end

Misc

How do I use it with Rails? (untested)

It seems that you can use the rbs_rails gem developed by @pocke (@p_ck_) to generate rbs for Rails, but I haven't tested it yet. I'm sorry. For more information, please refer to the rbs_rails repository and various web articles.

What is the relationship between Sorbet and RBS?

If you've been interested in Ruby type checking for a while, you may have heard of Sorbet.

https://sorbet.org

Sorbet is a type-checking tool that has been developed before RBS and Steep. Sorbet and RBS/Steep have the same purpose of "type checking for Ruby", but the mechanism to achieve this is different. The sorbet team and Ruby team will be working together to build a mutually compatible type checking mechanism. For more information, please see the following blog post (these are in English).

you can use RBS in Ruby 2.7

Not only steep, but RBS and TypeProf are provided as gem. Therefore, you can use these tools not only in Ruby 3 but also in Ruby.27. The followings are the result of these tools in Ruby 2.7.

$ ruby -v ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-darwin19] $ gem install rbs $ gem install typeprof $ gem install steep $ rbs prototype rb lib/fizz_buzz.rb class FizzBuzz def self.run: (untyped n) -> untyped end $ typeprof runner/fizz_buzz_runner.rb # Classes class FizzBuzz def self.run : (Integer) -> Array[String] end $ steep check

What does RBS stand for?

I'm not sure precisely what RBS stands for, but I'm guessing it stands for "Ruby Signature language". from this slide.

The sources of information about RBS / TypeProf / Steep.

In this article, I just introduced a "Hello World" for these tools. If you want to use the type-checking feature in production code, you might need more deep knowledge. If you want to know more, please refer to the following documents.

Official repositories for RBS/TypeProf/Steep

In each repository's README, You can find links to the more detailed documents.

Get to know the design concept.

Sotaro Matsumoto(@soutaro) has developed RBS and Steep, and Yusuke Endoh(@mame) has developed the TypeProf. You can read about the design concept of these tools from their articles and slides.

Try TypeProf in your browser.

You can try TypeProf in your browser at the following site. https://mame.github.io/typeprof-playground/

Summary

This article introduced Ruby's static type checking feature using RBS and related tools that Ruby3.0 provides. I tried to use RBS in some use cases, but if I wrote a bit complicated code, it makes it harder to write appropriate type definitions, and sometimes I got unexpected type errors. Honestly, it's a little bit hard to introduce these tools to a real project yet. Ruby is a highly dynamic language, so I felt providing a type definition from outside will be quite tricky. But I think it's a big step for Ruby to have official support for type checking finally. As the number of gem supports type definitions gradually increases and knowledge of writing type definitions accumulates, static type checking in Ruby will be standard in a few years.

You can read my other article for Ruby3.0 here!