Experiments with Gradual Typing in Ruby – Part 1

24 novembre 2020

I recently started toying with Gradual typing in Ruby.

Of course, Ruby is already typed: messages are dispatched to objects depending on their type. That’s dynamic typing, enforced at runtime.

But for larger programs, it can be useful to have some static type-checking, that can be enforced by a type-checker without running the whole program.

Enter type annotations. By adding some explicit informations about the expected types in our program, a type-checker will be able to catch some errors using a static analyzer.

What does it look like?

Gradual typing is not standardized in Ruby yet (although some efforts are ongoing). So there are different tools available. Currently, the best way to add gradual typing to Ruby programs seems to be Sorbet.

Here’s a simple Sorbet example, using an existing Ruby function:

def to_hex(i)
  "%x" % i
end

We can add type informations to this function using the sig function decorator:

extend T::Sig

sig {params(i: Integer).returns(String)}
def to_hex(i)
  "%x" % i
end

Granted, the syntax is a bit weird. But it has the merit of being valid Ruby code, which allows it to be accepted by the standard Ruby parser without modifications. And like many syntaxes that seem unusual at first, our eyes quickly get used to it (hi, Objective-C square brackets).

Letting “Gradual” shine

Now, the neat thing with gradual typing is that you don’t have to provide type informations everywhere. This is useful in many ways.

First, you may start adding type checks to an existing codebase. In that case, declaring all types from the start can be a daunting task. Fortunately, in the absence of types, the type-checker will consider that we know what you’re doing. Which means we can start adding a few types here and there, and already have useful type-checks – but the parts of the program which are still type-annotations-free will not results in warnings or errors.

Second, a lot of Ruby elegance and fun comes from its dynamic nature. Sometimes the most elegant way to express some code is to use dynamic method resolution, or other dynamic-oriented constructs which cannot be type-checked. And this is okay! In that case, gradual typing means you have an escape-hatch: as types are not mandatory, they just won’t be type-checked. We can get the benefits of type-checking for 95% of the code, and still use neat dynamic features in the remaining 5%.

And last, at times it can be useful to just experiment and prototype some code quickly, to see how the structure would look. In these cases, you won’t have to fight your way through types: you can just omit type annotations, and quickly let the code flow without thinking too much about production-ready reliability.

Experimenting with a standalone Ruby script

I’ve never used Sorbet before, so I wanted to start small, and get used to the type system.

Fortunately, the Sorbet website provides an online playground: just type in some Ruby code, add some type annotations, and the type checker will start telling you what’s right and wrong with the types you provided. Neat.

I used a standalone Ruby script I wrote some times ago. This script reads assembly source code and debug symbols from the local filesystem, and infers from this the probable location of more debug symbols. Here the initial version of the script, and the final version after finishing adding types.

When I copy-pasted the script, without adding anything yet, Sorbet immediately told me about two errors: <function> is not available on NilClass (https://srb.help/7003).

Wow: it detected that in two different places, my code was sending a message to a potentially nil object. And gave me an URL to learn more about the issue.

How to fix this? I followed the URL, and a well-written document explained me that I could either:

So the two reported errors were easy to fix. I was quite impressed that Sorbet found two relevant mistakes without even starting to add types. And even more impressed that it not only does nil-checks, but also type propagation (that is, when some code checks if a value is nil, Sorbet considers that after this point the variable can not longer be nil).

Adding types

After this, I started adding type annotations to a single method (a constructor). That was easy enough: just a matter of adding the correct sig {…} incantation.

But right after that, Sorbet told me about a new error: my signature stated that the method argument was a String, but elsewhere I was calling the constructor with a T.Nilable(String) – that is, an object that may be nil. Interesting. Like before, to fix it, I had to add the proper nil check before calling the constructor.

I then gradually added type annotations to more methods, and found it almost fun. I had the feeling that I was strengthening my program, and uncovering the hidden assumptions that had been there before.

All of this went rather smoothly (except having to convert Ruby Structs, unsupported by Sorbet, into T::Structs). The weird syntax quickly became bearable, and eventually even read like being a part of the documentation.

Refactoring

In the end, this even led me to write better code. For instance, consider this function :

class Address
  def self.from_string(str)
    bank, offset = str.split(':')
    self.new(bank.to_i(16), offset.to_i(16))
  end
end

It extracts the two components of a semicolon-separated string – like 03:4A2F.

When adding type annotations, Sorbet initially told me “Hey, offset.to_i(16) is not valid on NilClass”. Because of course, it detected that if the input string is badly formatted, offset may be nil.

So I quickly wrapped the value in T.must(…), to silent the warning away. After all, there’s not so much we can do to prevent badly-formatted input; crashing at runtime seems a sensible option.

class Address
  sig {params(str: String).returns(Address)}
  def self.from_string(str)
    bank, offset = str.split(':')
    self.new(T.must(bank).to_i(16), T.must(offset).to_i(16))
  end
end

But wait, there’s better than crashing at runtime: and that’s “crashing at runtime with a meaningful error message”. What if we rely on nil-propagation to write instead:

class Address
  sig {params(str: String).returns(Address)}
  def self.from_string(str)
    bank, offset = str.split(':')
    raise "Invalid address format" if bank.nil? || offset.nil?
    self.new(bank.to_i(16), offset.to_i(16))
  end
end

Nice: a bad input now gives us a readable error message. And we can even remove the T.must checks, because, thanks to nil-propagation, Sorbet is now sure that offset.to_i(16) is not called on nil.

So far

After toying with the Sorbet’ playground, here are my first impressions:

The good

The bad

The ugly

What’s next

For now I haven’t tried to type-check a program locally, nor to type-check code that relies on external gems.

So my next step is probably to add some minimal type-checking to a small Rails app, and see how Sorbet’s tooling deals with the many dynamic constructs of the framework.

Kudos

Discussion, liens, et tweets

J’écris des sites web, des logiciels, des applications mobiles. Vous me trouverez essentiellement sur ce blog, mais aussi sur Mastodon, parmi les Codeurs en Liberté, ou en haut d’une colline du nord-est de Paris.