Over the course of a career in software engineering, we learn to love elements of our tooling and dislike others – that’s perfectly natural.
As requirements change, including our own need to improve as engineers, so does what appeals to us when reaching for a new framework, language or library.
A common path for lots of engineers will have been to learn something like C or C++, both strongly typed languages where you need to define what kind of value a variable will be. Then onto something else, maybe Java or C#; these are more modern takes with huge standard libraries to accelerate us towards our desired results.
Then, maybe we move onto more dynamic languages like Ruby, or JavaScript. All of a sudden you can just build stuff with your whole focus on the logic. The nuts and bolts, and with fewer constraints to worry about things feel quicker.
Eventually though, pieces of your once loved toolset will appeal again, and you’ll maybe appreciate what they gave you before moving on to the next thing.
If you’re writing Ruby…why not have both?
Ruby type checking tools
Various tools have emerged over the last few years to add a level of type safety to Ruby.
One of the most popular is Sorbet which can add analysis and run-time type checking to your code, and it’s an amazing tool.
More recently RBS (Ruby Type Signatures) has been added to Ruby 3.0, with other tooling on the way from the Ruby core team members, such as Steep. It’s an exciting addition to the Ruby landscape, so let’s take a closer look at type checking in Ruby using RBS and Steep…
RBS (Ruby Signatures)
What is RBS? Well, it’s not a type-checker – it’s just a language that lets you describe your classes and methods.
Other tools like Steep or Sorbet perform the actual checking based on its signatures.
Steep along with another tool, Typeprof, are being built by the Ruby team to make adding types to your codebase easier. Typeprof is a new interpreter which can evaluate your Ruby code and output a candidate .rbs file for you.
Some IDEs (like Rubymine) have included RBS support to implement some hinting – like the squiggly lines you see under a method invocation that isn’t correct.
So, why is type checking useful?
Type checking can serve as a low level unit test for a method and its use in a system, but also provides accurate documentation for your API. Take this code for example:
# lib/greeting.rb class Greeting def say_hello(arg) puts "Hello, #{arg}" end end # main.rb require "./lib/greeting" greet = Greeting.new greet.say_hello("FreeAgent")
But what is arg
? It’s easy to see by looking at the code in this example that it’ll be output as a string, and thanks to Ruby it doesn’t really matter what we pass in as that parameter – it’ll work out how to output something, even if it’s just the class name/object id.
So the type here isn’t too much of a concern. We can, however, leave each other clues as to what it is, and be more helpful to our team and future selves. A comment, YARD docs, a more descriptive name or a spec would all improve this.
But over time one or more of those things may change and fall out of sync with what the method is doing or what it requires.
Let’s change things a bit so that we can call that method with a 2nd parameter but implement it incorrectly…
say_hello("FreeAgent", "Hi!")
You might assume that the 2nd parameter is some sort of prefix for the output. But this example is even more contrived than that!
def say_hello(name, count) count.times do puts "Hello, #{name}" end end
We’ve given arg
a more useful name, and added a new count parameter – the type of this new parameter is important since we’re calling a method on it. That method, #times
, is only defined on Integer types, so calling it on a string will lead to problems.
Despite that, say_hello("FreeAgent", "Hi!")
looked correct at first glance, and it is valid Ruby after all.
Without actually taking a look at the code to see what that 2nd parameter does, you wouldn’t know it was wrong until runtime when you would see a NoMethodError
exception because #times
is undefined on instances of String
. So, how can we avoid this and make things easier?
Enter type checking with RBS and Steep
Steep is a tool for parsing RBS files, and checking that the corresponding Ruby code has matching invocation and return signatures.
With Ruby 3 and the Steep gem installed, a Steepfile like this one is all you need to get started:
target :lib do signature "sig" # signatures in the sig/ folder check "lib" # type check ruby code in the lib/ folder check "main.rb" # type check our main/entrypoint file end
With Steep configured and watching our project (steep watch .), and by giving our Greeting class a type signature like this:
# sig/greeting.rbs class Greeting def say_hello: (String, Integer) -> void end
updating main.rb…
# main.rb require "./lib/greeting" greet = Greeting.new greet.say_hello("FreeAgent", "5")
you’ll see this output from Steep in your console:
[error] Cannot pass a value of type `::String` as an argument of type `::Integer` Diagnostic ID: Ruby::ArgumentTypeMismatch greet.say_hello("Test", "5") ~~~
It’s important to note that our main.rb will still run (and raise an exception) – think of the type checking step like a sort of spec run. A failing spec won’t stop you running the Ruby code it describes…but there may be trouble ahead if you ignore it.
Let’s fix up the code…
greet.say_hello("Test", 2)
# 🔬 Type checking updated files...done
No errors. Nice! And now running ruby main.rb gives us:
Hello, Test Hello, Test
Wrapping up
So, there we have it – a quick introduction to type checking in Ruby using RBS and Steep.
The future of type checking in Ruby looks really exciting with some mature tools already having widespread adoption. Give it a try and see what works best for you. Have fun!