31 Jan 2023 Β· Software Engineering

    Ease Into Ruby 3’s Static Typing Powers With RBS, TypeProf and Steep

    17 min read
    Contents

    I started studying Ruby in 2012. I’m not quite a dinosaur, because when I began my journey, there were already experienced Rubyists out there. Nonetheless, this was a time when Ruby was a proudly dynamic language and Rails 3 was all the rage.

    Don’t get me wrong, at present, Ruby still is a proudly dynamic programming language! In the last few years, however, Matz and the community have started to entertain the idea of adding some level of static type checking to the language. One of the pioneers in this wild west phase was Stripe’s Sorbet. On the 25th of December, Ruby 3.0 finally brought us an official answer to this in the form of RBS, a standard type language for Ruby.

    It’s normal during the career of a developer to move away – at least temporarily — from what once was their main language. That was the case for me just before the release of Ruby 3.0 and I suppose that this has also happened with many other folks. I’m writing this article having us older Rubyists as the primary audience, but if you’re a beginner, however, don’t shy away: I’m certain this blog post will also have value for you, if you aren’t yet familiar with RBS, TypeProf and other citizens of this new ecosystem of static analysis related tools in Ruby.

    In this article, I’ll explain what RBS is (and what it is not). I’ll also show how to use it step by step and conclude this blog post with my thoughts on this trend and how to best use static type checking in real-world Ruby programs.

    All of the code shown in this blog post is available in a companion Git repository. You can find it at https://github.com/alexbrahastoll/semaphore-1-ruby-3-rbs-typeprof

    RBS: what it is and what it is not

    Recently, when I began programming a lot in Ruby again, I was naturally curious about the new features introduced in 3.0. At first, I thought that RBS, besides being able to provide typing for your Ruby program, could also be used to perform static type checking. This is actually not the case! With .rbs files you can specify which types are used where, but RBS doesn’t concern itself with the meaning of each type (i.e. their implementation) nor with providing type checking (i.e. with analyzing your program without running it and checking whether types are actually used in the places they are supposed to). To sum all this up, RBS is only a type language, not a complete type checker. Nonetheless, RBS is an important official addition to the Ruby ecosystem, as it is the standard type language, thus avoiding the proliferation of third-party solutions and the fragmentation of the user base and complementary tools. By the way, RBS is supposed to stand for Ruby Signatures, although I didn’t find information on the exact meaning of the acronym on the official Ruby site, only loose references on third party sources across the Web.

    If we think of RBS as a project, it’s also concerned with providing type signatures for both the core and standard libraries. As you’ll soon see, this is of the utmost importance. As even a simple Ruby program will use the core and standard libraries, it’s fundamental to have these typed, otherwise a type checker wouldn’t be able to do much.

    Furthermore, to be completely honest, RBS does come with a tool to dynamically check for type errors by injecting itself into a test suite, but this is not static type checking and is dependent on both a test suite being available and actually decently exercising the system under test. More info on how to use this tool is available at RBS’ GitHub repository.

    Our first steps using RBS and TypeProf

    To showcase RBS, let’s use a small program that I wrote to calculate the number of working days in a given period. As inputs, the program receives a start date, an end date, and an array of dates representing all the days off for the period.

    At first, we will have a very simple and more verbose implementation of this working days calculator. The idea is to test how the different tools in the ecosystem can handle a not so idiomatic – but very straightforward — implementation. Then, we will move to a more concise and idiomatic implementation of the same program and repeat the process.

    Here’s how our program looks like:

    # working_day_calculator.rb
    
    require 'date'
    
    class WorkingDayCalculator
      attr_accessor :period_start, :period_end
      attr_reader :days_off
    
      def initialize
        @days_off = []
      end
    
      def add_day_off(date)
        @days_off << date
      end
    
      def calculate_working_days
        ((period_start..period_end).to_a - days_off).length
      end
    end

    RBS comes with a tool able to generate signatures from Ruby source code. This tool is called TypeProf. The signatures it generates, however, are only a starting point and generally need to be manually enhanced in order to actually have any value in practice.

    To generate signatures for our calculator, we run the following command:

    typeprof working_day_calculator.rb

    TypeProf analyzes our program and then outputs the following:

    # TypeProf 0.21.3
    
    # Classes
    class WorkingDayCalculator
      attr_accessor period_start: bot
      attr_accessor period_end: bot
      attr_reader days_off: Array[untyped]
      def initialize: -> void
      def add_day_off: (untyped date) -> Array[untyped]
      def calculate_working_days: -> untyped
    end

    We can then copy and paste what really matters into a new file, working_day_calculator.rbs, which is the corresponding type signature file for our calculator.

    Before moving on and trying to have some type checking done for us, let’s see what an RBS file looks like. It’s out of the scope of this post to provide a complete reference to its syntax, but let’s at least go through the basics. A complete reference is available at RBS’ GitHub repository.

    As is clear from the RBS snippet above, the type language’s syntax is very similar to the syntax of Ruby itself. In the first two lines, we indicate that our WorkingDayCalculator class has methods for reading and writing the period_start and period_end instance variables. TypeProf was not able to figure out the types of these variables, so it marked them as bot, which is short for bottom – bot is a subtype of all types.

    Then, TypeProf detected that we have a reader for @days_off. Furthermore, it was also able to figure out that @days_off is an array! Since it couldn’t determine which type or types are to be held inside @days_off, it generated an Array[untyped]signature for this instance variable.

    Finally, we have the signatures for the methods of WorkingDayCalculator. As an example, let’s take a look at the one generated for #add_day_off. TypeProf correctly identified that we expect one argument called date, but it was unable to determine the type of date, so it marked it as untyped. TypeProf detected that we return an array for this method (which is actually the instance variable @days_off), however, this is not that important. Here, the return value of #add_day_off is a consequence of Ruby implicitly using as return value the last evaluated expression of a method when an explicit return is not present or reached.

    A simple test script that uses our calculator

    In order for us to be able to type check our code in a more realistic manner, it’s better if we have code that uses the WorkingDayCalculator class. A simple script that gives some valid inputs to an instance of the calculator and then calls #calculate_working_days will serve us perfectly. Here’s our test script:

    # working_day_calculator_test.rb
    
    require_relative './working_day_calculator'
    
    calc = WorkingDayCalculator.new
    calc.period_start = Date.new(2023, 1, 1)
    calc.period_end = Date.new(2023, 1, 31)
    calc.add_day_off(Date.new(2023, 1, 1))
    
    puts calc.calculate_working_days # Expected: 30

    In the next section, we’ll finally start experimenting with type checking. The script above will be our playground and we will change it and introduce type errors on purpose to see some actual type checking in action.

    You reap what you sow: type checking typed Ruby code with Steep

    Now that we have very basic signatures for our code, we should be able to start type checking it. As explained before, these batteries are not included with RBS’ package. There are, however, multiple tools in the Ruby ecosystem already leveraging RBS. In this blog post, we’re going to use Steep. Available as a gem, Steep is a type checker created by Soutaro Matsumoto – a Ruby Core committer.

    Steep requires a configuration file named Steepfile to be present in the directory from which the included CLI is run. A basic Steepfile can be generated with the command steep init. Look up Steep documentation for more information on the anatomy of a Steepfile. Here’s how ours looks like:

    # Steepfile
    
    D = Steep::Diagnostic
    target :lib do
      signature 'sig'
    
      check 'working_day_calculator_test.rb'
    
      configure_code_diagnostics(D::Ruby.strict)
    end

    Now, we can proceed and run steep check to initiate the type checker. After a few seconds, you should see an output similar to the one below:

    # Type checking files:
    
    ..................................................................................
    
    No type error detected. πŸ§‰

    Before you get all up in arms, I’m going to say that I agree that this is not impressive, but at least things seem to be working! Can Steep detect type errors with our current RBS files? Let’s check and see by introducing an error to our test script on purpose:

    # working_day_calculator_test.rb
    
    require_relative './working_day_calculator'
    
    calc = WorkingDayCalculator.new
    calc.period_start = Date.new(2023, 1, 1)
    calc.period_end = Date.new(2023, 1, 31)
    
    # calc.add_day_off(Date.new(2023, 1, 1)) # original, correct usage of #add_day_off
    calc.add_day_off('BOOM') # incorrectly passing a String to #add_day_off
    
    puts calc.calculate_working_days # Expected: 30

    Now, when we run steep check again, we should see a type error, right? Unfortunately, that is not the case:

    # Type checking files:
    
    ..................................................................................
    
    No type error detected. πŸ«–

    This might seem strange at first, but is no surprise when we check more carefully our RBS file for the WorkingDayCalculator class. Since the signature of #add_day_off expects an untyped argument, Steep is not able to determine that passing a String to the method should constitute a type error.

    For our current signature file to be useful, we have to manually enhance it. Let’s indicate, where appropriate, that dates are expected to be a Date type. Here’s our enhanced RBS file:

    # working_day_calculator.rbs
    
    class WorkingDayCalculator
      attr_accessor period_start: Date
      attr_accessor period_end: Date
      attr_reader days_off: Array[Date]
      def initialize: -> void
      def add_day_off: (Date date) -> Array[Date]
      def calculate_working_days: -> untyped
    end

    Now, when we run Steep again, here’s its output:

    # Type checking files:
    
    ..............................................................................F..F
    
    sig/working_day_calculator.rbs:2:30: [error] Cannot find type `Date`
    β”‚ Diagnostic ID: RBS::UnknownTypeName
    β”‚
    β””   attr_accessor period_start: Date
                                    ~~~~
    
    working_day_calculator_test.rb:10:7: [error] UnexpectedError: sig/working_day_calculator.rbs:2:30...2:34: Could not find Date
    β”‚ Diagnostic ID: Ruby::UnexpectedError
    β”‚
    β”” calc = WorkingDayCalculator.new
             ~~~~~~~~~~~~~~~~~~~~~~~~

    Note: if you want to experiment with the code shown above, check out the v1-step1 tag in this article’s companion repository.

    It’s nice to see some errors, but not quite the ones we were expecting, right? Remember that I explained above that part of the efforts of the team responsible for creating and evolving RBS is to provide signatures for Ruby’s core and standard libraries? The errors we just saw are due to the fact that we are using a type (Date) that Steep doesn’t recognize. Since the Date class is part of Ruby’s standard library, signatures for it are available. A simple change to our Steepfile is all that is needed:

    # Steepfile
    
    D = Steep::Diagnostic
    target :lib do
      signature 'sig'
    
      check 'working_day_calculator_test.rb'
    
      library 'date' # Instructs Steep to load the signatures for the date library.
    
      configure_code_diagnostics(D::Ruby.strict)
    end

    Heads up: library names should follow the naming convention used to load them! Since we require 'date', we must use library 'date'(using library 'Date' will result in an error).

    After making this adjustment to our Steepfile, we finally see our first static detection of a type error in Ruby code:

    # Type checking files:
    
    ....................................................................................F
    
    working_day_calculator_test.rb:13:17: [error] Cannot pass a value of type `::String` as an argument of type `::Date`
    β”‚   ::String <: ::Date
    β”‚     ::Object <: ::Date
    β”‚       ::BasicObject <: ::Date
    β”‚
    β”‚ Diagnostic ID: Ruby::ArgumentTypeMismatch
    β”‚
    β”” calc.add_day_off('BOOM')
                       ~~~~~~~~~~
    
    Detected 1 problem from 1 file
    

    Note: if you want to experiment with the code shown above, check out the v1-step2 tag in this article’s companion repository.

    Improved Ruby, improved RBS

    Now, let’s improve the implementation of our calculator. To make it much more succinct and idiomatic, let’s throw away all other methods with the exception of #calculate_working_days. For the period under consideration, we now expect a Range of dates. For days_off, an array of ranges of dates and/or dates must be given. With these modifications, we end up with some much more rubyesque code, listed below:

    # working_day_calculator.rb
    
    require 'date'
    
    class WorkingDayCalculator
      def calculate_working_days(period, days_off)
        flattened_days_off = days_off.map { |date| date.class == Range ? date.to_a : date }.flatten
        (period.to_a - flattened_days_off).length
      end
    end

    Using Typeprof, we can generate a starting point for our RBS type signatures:

    # working_day_calculator.rbs
    
    class WorkingDayCalculator
      def calculate_working_days: (untyped period, untyped days_off) -> untyped
    end

    Note: if you want to experiment with the code shown right above, check out the v2-step1 tag in this article’s companion repository.

    We know that the generated RBS will not be good for detecting any type errors. So, let’s improve it and add signatures that perfectly describe this second version of the WorkingDayCalculator class:

    # working_day_calculator.rbs
    
    class WorkingDayCalculator
      def calculate_working_days: (Range[Date] period, Array[Range[Date] | Date] days_off) -> Integer
    end

    Our improved RBS file nicely illustrates the expressiveness of Ruby type signatures. The Range type allows for all kinds of ranges (Integer, Date and so on). If Ruby were a typed language, this would mean that the Range class would have a generic data type as a parameter in its definition (something like Range[T]). In our case, we want to indicate that we expect a specific type of range, specifically one of dates. We can do so by designating the type of period as Range[Date].

    Regarding the days_off parameter, here we need to indicate that we expect an array that can be composed of any combination of ranges of dates and date objects themselves. That is to say that the type of days_off‘s array is a union type or, in other words, a type representing multiple possible types. We can express this concept as the following: Array[Range[Date] | Date].

    Finally, we should also indicate that #calculate_working_days has a return type. The value returned by our method will always be an integer, which we represent with -> Integer.

    As we did in the first part of this article, we will have a file that uses WorkingDayCalculator so we can purposefully have examples of incorrect usages. These type errors are explained in the gist below alongside the code itself. We expect Steep to be able to detect them.

    # working_day_calculator_test.rb
    
    require_relative './working_day_calculator'
    
    calc = WorkingDayCalculator.new
    period_start = Date.new(2023, 1, 1)
    period_end = Date.new(2023, 1, 31)
    days_off = [Date.new(2023, 1, 1), Date.new(2023, 1, 29)..Date.new(2023, 1, 30)]
    
    # correct! prints "28"
    puts calc.calculate_working_days(period_start..period_end, days_off)
    
    # type error, arg 1 must be a range of dates
    calc.calculate_working_days([], days_off)
    
    # type error, arg 2 must be an array composed of dates and / or ranges of dates
    calc.calculate_working_days(period_start..period_end, Date.new(2023, 1, 1))
    
    # type error, arg 2 must be an array composed of only dates and / or ranges of dates
    calc.calculate_working_days(period_start..period_end, [
      Date.new(2023, 1, 1),
      '2023-01-15',
      Date.new(2023, 1, 29)..Date.new(2023, 1, 30)])

    Now, we can run steep check and see if it detected the type errors present in working_day_calculator_test.rb. Here’s its output:

    # Type checking files:
    
    ....................................................................................F
    
    working_day_calculator_test.rb:12:28: [error] Cannot pass a value of type `::Array[untyped]` as an argument of type `::Range[::Date]`
    β”‚   ::Array[untyped] <: ::Range[::Date]
    β”‚     ::Object <: ::Range[::Date]
    β”‚       ::BasicObject <: ::Range[::Date]
    β”‚
    β”‚ Diagnostic ID: Ruby::ArgumentTypeMismatch
    β”‚
    β”” calc.calculate_working_days([], days_off)
                                  ~~
    
    working_day_calculator_test.rb:15:54: [error] Cannot pass a value of type `::Date` as an argument of type `::Array[(::Range[::Date] | ::Date)]`
    β”‚   ::Date <: ::Array[(::Range[::Date] | ::Date)]
    β”‚     ::Object <: ::Array[(::Range[::Date] | ::Date)]
    β”‚       ::BasicObject <: ::Array[(::Range[::Date] | ::Date)]
    β”‚
    β”‚ Diagnostic ID: Ruby::ArgumentTypeMismatch
    β”‚
    β”” calc.calculate_working_days(period_start..period_end, Date.new(2023, 1, 1))
                                                            ~~~~~~~~~~~~~~~~~~~~
    
    working_day_calculator_test.rb:18:54: [error] Cannot pass a value of type `::Array[(::Date | ::String | ::Range[::Date])]` as an argument of type `::Array[(::Range[::Date] | ::Date)]`
    β”‚   ::Array[(::Date | ::String | ::Range[::Date])] <: ::Array[(::Range[::Date] | ::Date)]
    β”‚     (::Date | ::String | ::Range[::Date]) <: (::Range[::Date] | ::Date)
    β”‚       ::String <: (::Range[::Date] | ::Date)
    β”‚         ::String <: ::Range[::Date]
    β”‚           ::Object <: ::Range[::Date]
    β”‚             ::BasicObject <: ::Range[::Date]
    β”‚
    β”‚ Diagnostic ID: Ruby::ArgumentTypeMismatch
    β”‚
    β”” calc.calculate_working_days(period_start..period_end, [
                                                            ~
    
    Detected 3 problems from 1 file

    As shown in the snippet above, Steep was able to detect all type errors present in working_day_calculator_test.rb. Quite an impressive result, if you ask me! This shows that, if one puts the effort into creating good type signatures, the tool can indeed detect complex kinds of type errors.

    Note: if you want to experiment with the code shown right above, check out the v2-step2 tag in this article’s companion repository.

    Type Checking in Ruby: the great and the not so great

    Ruby 3 introduced RBS, an official type language for Ruby. Alongside community tools such as Steep, it affirmed that Ruby 3 has now become a programming language with gradual typing. This is positive because it allows teams to increase the reliability of their products and stay on par with the trend of gradually-typed languages (as you probably know, many developers care a lot about being fashionable).

    It’s certainly good to have more rather than less tools to help us make our Ruby programs more reliable. Does this mean that typing in Ruby solves all reliability problems software can have? If that was the case, it wouldn’t be necessary to write automated tests when programming in typed languages, so the answer here is obviously no. Does this mean all Ruby projects should now have RBS type signatures for 100% of the codebase? Also, I think that would defeat the great strength of Ruby as a highly dynamic language, so the answer is also no in my opinion.

    As an experienced Ruby developer, I see RBS type signatures being used mainly in two scenarios. First, in larger teams working on mature codebases. In such an environment, it’s plausible to imagine that adding type signatures at least to the more complex or mission critical parts of the codebase might result in an increase in overall reliability. The second case in which I believe that types might be useful is for programs, or parts of programs, for which it’s hard to write and maintain automated tests for. Again, investing in type signatures in these cases may result in increased product reliability.

    What about you? Has this blog post made you curious about RBS, Typeprof and Steep? If so, I highly recommend that you check the resources mentioned throughout this article and do your own experiments using gradual typing in Ruby!

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Alex Braha Stoll
    Writen by:
    Alex is a software developer that cannot get tired of attempting to write the next line of code at least a little better than the one before it. In his free time, he is always looking for that next awesome hobby.
    Avatar
    Reviewed by:
    I picked up most of my soft/hardware troubleshooting skills in the US Army. A decade of Java development drove me to operations, scaling infrastructure to cope with the thundering herd. Engineering coach and CTO of Teleclinic.