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.
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.
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.
You can find the sample code used in this article in this repository.
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.
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)
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")
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.
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
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.
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?
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
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.
If you've been interested in Ruby type checking for a while, you may have heard of Sorbet.
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).
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
I'm not sure precisely what RBS stands for, but I'm guessing it stands for "Ruby Signature language". from this slide.
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.
In each repository's README, You can find links to the more detailed documents.
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.
You can try TypeProf in your browser at the following site. https://mame.github.io/typeprof-playground/
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!