DEV Community

Cover image for Static Typing - Ruby's missing tools
Andrey Eremin
Andrey Eremin

Posted on

Static Typing - Ruby's missing tools

For the last 20 years, Rubyists have adopted dozens of tools and technologies that allow us to write better software, scale projects, and ship what needs to be shipped to production the way we want it. I will name just a few of them: Docker, ruby-lsp, AI, RuboCop, MiniTest, RSpec, Cucumber.

The interesting fact, however, is that all these tools faced criticism when they were introduced. Some were heavily criticized, others faced a little skepticism. But the fact is, eventually, we adopted them and now it’s hard to imagine our programming life without them. We no longer argue about spaces or tabs; we just do gem install rubocop and then rubocop -a. We adopted these tools so that we could achieve even more. We delegated part of what we were doing to these artificial electronic helpers.

Think about it. The first version (and some subsequent ones as well) of Ruby on Rails was implemented by DHH in TextMate with just syntax highlighting. No code completion, no linters, no IDEs, no AIs. I remember those days. I was using Notepad++ on Windows for PHP and Ruby development.

As we see across the years, the process of adopting new tools and new ways to help us ship more, faster, and better is endless. If we cannot come up with something internally, like RuboCop, we look elsewhere and adopt things used in other ecosystems like Docker, or MiniTest (which is an adaptation of a Java library).

For the last several years, I have been actively working with JVM languages like Java and Kotlin. When I first started working with them, I was quite irritated by the amount of code and things I needed to do just to run my program. Over time, however, I noticed the significant confidence I gained every time the compiler successfully built my code. It doesn’t guarantee that the code works as I expect, but it already gives me a lot - a full understanding of the data flow and all the connections between various parts of my code. My software may still crash once started, but I’m still certain that a big deal of correctness is already ensured. With a few additional tests, I can make sure the business logic works just as it should.

What about Ruby? Without a single line of tests, you don’t know anything about your code. Do you pass the correct data? Does your business logic even make sense? With simple scripts, you might guess and imagine how the code works, but will you be right? What about a simple refactoring or a feature request where you have no idea what kind of input you’re dealing with? Are they simple primitives like Strings or Integers? Or maybe instances of some classes or, even worse, nested Arrays or Hashes?

The only ways to check this are either to run the code and see, or to write tests. So we go with tests:

  • Tests to ensure the code accepts the correct data.
  • Tests to ensure the business logic is correct.
  • Tests to ensure edge cases won’t cause issues.

Tests are great, don’t get me wrong. But looking at the picture I described above, it seems we rely too much on one single source of confidence. What if they fail? And this happens. What if the tests are wrong? Maybe we stubbed too much and now we’re testing stubs, not the real code. No one will save us then. It looks like it’s time to look around and see what else we can do to make things better.

Here is a simple Ruby script:

# frozen_string_literal: true

require 'json'
require 'time'
require 'httparty'

module RubyVersion
  class Response
    attr_reader :code
    attr_reader :json 

    def initialize(code:, json:)
      @code = code
      @json = json
    end

    def success? = (200..299).include?(code)
  end

  API_URL = 'https://api.github.com/repos/ruby/ruby/releases/latest' 

  def self.fetch_response
    http = HTTParty.get(API_URL, headers: { 'User-Agent' => 'static-typing-demo' })
    Response.new(code: http.code.to_i, json: JSON.parse(http.body))
  end

  def self.fetch(printer)
    resp = fetch_response
    raise "HTTP #{resp.code}" unless resp.success?

    data = resp.json
    published_at = Time.parse((data['published_at'] || Time.now.utc.iso8601).to_s)
    printer&.call((data['tag_name'] || data['name']).to_s, data['html_url'], published_at)
  end
end

PRINTER = proc { |tag, url, published_at|
  puts "Fetched tag=#{tag} published_at=#{published_at.utc.iso8601} (url=#{url})"
  nil
} 

puts RubyVersion.fetch(PRINTER) if $PROGRAM_NAME == __FILE__

Enter fullscreen mode Exit fullscreen mode

Before we move on, let’s check what this code does. The script fetches information about the latest Ruby release via the .fetch_response method. The information is returned as an instance of the RubyVersion::Response class. The .fetch_response method is called by our main entry point .fetch, which accepts one mandatory parameter printer - a Proc defined in the constant PRINTER.

Just like we would do in real life, we’ll cover this code with the necessary tests. Here is a simple MiniTest file that covers all major aspects of our script.

# frozen_string_literal: true

require 'minitest/autorun'
require_relative '../app/fetch_ruby_latest_with_types'

class TestRubyVersionFetch < Minitest::Test
  def test_fetch_response_structure
    resp = RubyVersion.fetch_response
    assert_kind_of RubyVersion::Response, resp
    assert_kind_of Integer, resp.code
    assert_kind_of Hash, resp.json
  end

  def test_fetch_with_printer_proc
    received = {}
    printer = proc do |tag, url, published_at|
      received[:tag] = tag
      received[:url] = url
      received[:published_at] = published_at
      nil
    end
    summary = RubyVersion.fetch(printer)
    assert_nil summary
    assert received[:tag]
    assert received[:url]
    assert_kind_of Time, received[:published_at]
  end

  def test_fetch_without_printer
    summary = RubyVersion.fetch(nil)
    assert_nil summary
  end
end

Enter fullscreen mode Exit fullscreen mode

Let’s check what we do here. With test_fetch_response_structure, we ensure that .fetch_response works correctly, doesn’t raise exceptions, and returns a non-empty response of the correct type. With test_fetch_with_printer_proc, we cover our main business logic of accepting the Proc, calling .fetch_response, and passing all the necessary data to it. Finally, with test_fetch_without_printer, we test what happens when we pass nil. Even though the printer parameter is defined as mandatory, without any guard statement, we can still pass any value — including nil.

Now let’s add a file with type definitions.

module RubyVersion
  class Response
    attr_reader code: Integer
    attr_reader json: Hash[String, untyped]

    def initialize: (code: Integer, json: Hash[String, untyped]) -> void
    def success?: () -> bool
  end

  API_URL: ::String

  def self.fetch_response: () -> Response
  def self.fetch: (^(String, String, Time) -> nil) -> nil
end

PRINTER: ^(String, String, Time) -> nil

Enter fullscreen mode Exit fullscreen mode

For simplicity, I didn’t provide detailed type definitions for the Hash we receive from GitHub’s endpoint. In real life, we might want to go deeper and cover that part with types as well.

The syntax of RBS is very similar to Ruby, so it’s not difficult to understand what it describes. You may notice that for our .fetch method, we specified what type of data it supports. This means we don’t just “allow” a printer parameter to be present (though nil is possible), but we describe exactly what kind of value we accept. The same applies to all other parameters and return values.

Let’s break the code a bit and pass nil to the .fetch method like this:
puts RubyVersion.fetch(nil) if $PROGRAM_NAME == __FILE__.

When we run steep check, it will immediately spot the issue:

> bundle exec steep check
# Type checking files:

.F

app/fetch_ruby_latest_with_types.rb:49:23: [error] Cannot pass a value of type `nil` as an argument of type `^(::String, ::String, ::Time) -> nil`
│   nil <: ^(::String, ::String, ::Time) -> nil
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ puts RubyVersion.fetch(nil) if $PROGRAM_NAME == __FILE__
                         ~~~

Detected 1 problem from 1 file
Enter fullscreen mode Exit fullscreen mode

It tells us:
Cannot pass a value of type nil as an argument of type ^(::String, ::String, ::Time) -> nil
— which gives us enough information about the issue and how to fix it. Now, without even running our tests, we can ensure we’ll never pass invalid data.

That also means we no longer need this test:

  def test_fetch_without_printer
    summary = RubyVersion.fetch(nil)
    assert_nil summary
  end
Enter fullscreen mode Exit fullscreen mode

Steep (a static type checker) simply won’t let us pass anything but a Proc of the right shape. We can go even further and simplify other tests.

# frozen_string_literal: true

require 'minitest/autorun'
require_relative '../app/fetch_ruby_latest_with_types'

class TestRubyVersionMinimal < Minitest::Test
  def test_fetch_response_structure
    resp = RubyVersion.fetch_response
    refute_nil resp
  end

  def test_fetch_with_printer_proc
    received = {}
    printer = proc do |tag, url, published_at|
      received[:tag] = tag
      received[:url] = url
      received[:published_at] = published_at
      nil
    end
    summary = RubyVersion.fetch(printer)
    assert_nil summary
    assert received[:tag]
    assert received[:url]
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, for the remaining cases, we only test the logic, not the types or the shape of the data we deal with.

In other words, we split responsibilities between MiniTest and Steep: the first validates that the business logic works as expected, the latter ensures we always deal with what was designed.

Static typing in Ruby cannot and will never replace tests. Similar to how it works in strongly typed languages, it’s a tool that brings clarity and confidence to your code.

And just like you’d spend your time in, say, Java, working with RBS or Sorbet (whichever you prefer) is not an “extra” time that none of us has. It’s the same time you’d otherwise spend debugging your app manually or writing an excessive amount of tests. As we’ve seen many times before - using static typing pays off.

Top comments (0)