The Relicans

loading...

Why I Am A Fan of Ruby 3's Static Type Checking Features

wyhaines profile image Kirk Haines Updated on ・13 min read

First, A TL;DR About Dynamic and Static Typing

Imagine a World where dynamic languages had static type checking

There are four terms that are important when reasoning about typing in a computer language. We have languages that are dynamically typed, and languages that are statically typed. Those languages will also either be weakly typed or strongly typed.

Before addressing what static or dynamic typing is, it is useful to know what strong and weak typing is.

Strong Typing

Strong typing just means that the language is strict about mixing items of different types. These languages will tend to throw errors if data with different types are combined in unexpected ways. Examples of this are Java and Ruby. For example, adding a number and a string together in Ruby will result in an error:

2.7.2 :002 > 7 + "a"
Traceback (most recent call last):
        6: from /usr/local/rvm/rubies/ruby-2.7.2/bin/irb:23:in `<main>'
        5: from /usr/local/rvm/rubies/ruby-2.7.2/bin/irb:23:in `load'
        4: from /usr/local/rvm/rubies/ruby-2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
        3: from (irb):1
        2: from (irb):2:in `rescue in irb_binding'
        1: from (irb):2:in `+'
TypeError (String can't be coerced into Integer)
Enter fullscreen mode Exit fullscreen mode

Weak Typing

A weakly typed language, on the other hand, tries to figure out how to make data of different types work together without throwing errors. Javascript, Perl, PHP, and C are all examples of weakly typed languages. For example, with Javascript:

> "a" + 7
> "a7"
> 7 + "a"
> "7a"
Enter fullscreen mode Exit fullscreen mode

The other axis to typing is dynamic and static typing.

Dynamic Typing

Dynamic typing means that the type constraints are checked at run time. This means that types are not enforced until the program is run. Thus, Ruby is dynamically typed, as are many other languages such as Perl, Python, Erlang, PHP, and Javascript. These languages are all considered to be dynamic because type constraints are not enforced until the code is executed.

For example, in Ruby:

class Foo
  def foo(x)
    x + 7
  end
end

f = Foo.new
puts f.foo(4)
puts f.foo("4")
Enter fullscreen mode Exit fullscreen mode

This program has an error. As demonstrated in the earlier example, while two numbers can be added together, a Ruby Integer and a Ruby String can not be added together without creating a TypeError. The error will not be revealed until the program is executed, however.

❯ ruby foo.rb
11
foo.rb:3:in `+': no implicit conversion of Integer into String (TypeError)
    from foo.rb:3:in `foo'
    from foo.rb:9:in `<main>'
Enter fullscreen mode Exit fullscreen mode

You can see that the first number is printed, as the program runs fine to that point. However, the next instruction, which contains the type constraint violation, triggers an exception, and the program dies at that point.

Static Typing

Statically typed languages, on the other hand, have their type constraints checked before the code is executed. Typically, this means that types are checked during compilation. Some of the more well-known or common statically typed languages include C-like languages like C/C++ or Java, as well as old school languages like COBOL and FORTRAN, and newer languages like Go, Rust, Crystal, and Nim.

This is the same program as above, written as a statically type-checked Crystal program:

class Foo
  def foo(x)
    x + 7
  end
end

f = Foo.new
puts f.foo(4)
puts f.foo("4")
Enter fullscreen mode Exit fullscreen mode

Crystal will not run the program at all, as, at compile-time, it checks the type constraints, and throws an error.

❯ crystal build foo.rb
Showing last frame. Use --error-trace for full trace.

In foo.rb:3:7

 3 | x + 7
       ^
Error: no overload matches 'String#+' with type Int32

Overloads are:
 - String#+(other : self)
 - String#+(char : Char)
Enter fullscreen mode Exit fullscreen mode

There is one other complication to consider. Some languages, such as Javascript and Ruby, which are historically dynamic languages, now have statically typed versions.

For Javascript, its statically typed doppelganger is called TypeScript, and for Ruby, static typing was introduced with version 3.0.0 of the language (though it was possible to do static typing via an optional package called Sorbet before Ruby 3.0.0). The wrinkle here is that in both of those cases, typing is optional. Its opt-in nature means that the dynamic nature of both languages is left intact while leaving open the possibility that the developer can leverage static typing if they so desire.

So What Is All The Fuss?

There are many arguments both for and against both dynamic and static type checking, but in general, they come down to a trade-off between developer-friendliness or ease of development versus code safety and early detection of errors.

This debate is, of course, more nuanced than this, but that is still the high-level summary of the contrast.

The idea is that if one doesn't have to worry about type constraints while writing the code, and if one can write code where, if an object can provide an interface that acts like that of a different type, it can be treated as that type. In the Ruby world, this is referred to as Duck Typing:

If it looks like a duck, swims like a duck, and quacks like a duck, it is probably a duck.

If the type checking can stay out of the developer's way, the developer can be more productive and can write more flexible code. Entire articles can be (and have been) written about duck typing, but essentially, it is just the idea that if an object has the methods that it is expected to have, it doesn't matter what the class or type of the object actually is.

Furthermore, to borrow a quote from the previously mentioned article:

Constantly wrapping method calls in begin/rescue/end is pretty heinous from any angle; aesthetics, readability or performance. While hard duck-typing might come off as a little cavalier, it’s pretty much assumed in the Ruby community you have a test suite to make sure none of these NoMethod exceptions end up in production.

Many uses of duck typing make few or no proactive type checks, and they also frequently do not contain any specific error handling, as implied in the above quote.

And, a lot of the time, this works fine, but it definitely does represent a tradeoff. The tradeoff is that it is easier for bugs to leak into production that may not be immediately noticed, as they might depend on a particular uncommon set of circumstances to expose them.

One of the defenses against this risk, also mentioned in the above quote, is to ensure that tests are written that have the coverage to expose errors like this, but testing is always itself a tradeoff of coverage and thoroughness versus the time taken to develop the tests, the incremental time burden on CI to run the tests, and the developer time required to maintain the tests over time.

On the flip side, static constraint checking can be thought of as a set of thorough code tests that one gets for free. It's a low-effort benefit for the developer to help them to be sure that a broad class of easy-to-make errors does not survive in runnable code.

Another benefit of static typing is that it makes it easier to very clearly document for other developers exactly what any given piece of code is expecting as inputs, and what it returns as an output. This can be particularly helpful as a codebase grows in size.

It is a debate with no correct answer, but given the benefits that do come with static typing, Ruby 3.0, like TypeScript with JavaScript, adds static typing to Ruby, as an optional feature.

What Does Optional Static Typing Mean?

This means that Ruby programs that make no use of the static typing features of Ruby will continue to operate exactly as they have before. There is no negative consequence of not using the static typing system.

This also means that typing can be added to a codebase slowly, in modest increments, which makes it much easier to retrofit static type checking to established codebases.

Take, as an example, the following program:

class Foo
  def foo(x)
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    end
  end
end

f = Foo.new
puts "b: #{f.foo("b")}"
puts "d: #{f.foo("d")}"
Enter fullscreen mode Exit fullscreen mode

This program runs just fine with Ruby:

❯ ruby a.rb
b: 2
d: 
Enter fullscreen mode Exit fullscreen mode

It also runs identically in Crystal:

❯ crystal run a.rb
b: 2
d: 
Enter fullscreen mode Exit fullscreen mode

Let's increase the stakes a little, and make the return value of #foo actually do something.

class Foo
  def foo(x)
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    end
  end

  def double(x)
    foo(x) + foo(x)
  end
end

f = Foo.new
puts "b: #{f.double("b")}"
puts "d: #{f.double("d")}"
Enter fullscreen mode Exit fullscreen mode

When this code is executed with Ruby, an error is thrown:

❯ ruby a.rb
b: 4
a.rb:14:in `double': undefined method `+' for nil:NilClass (NoMethodError)
    from a.rb:20:in `<main>'
Enter fullscreen mode Exit fullscreen mode

Just like the earlier, much simpler example, Ruby failed at run time with an error.

A statically typed language like Crystal will, as with the earlier examples, fail on code like this with an error during compilation:

❯ crystal build a.rb
Showing last frame. Use --error-trace for full trace.

In a.rb:14:12

 14 | foo(x) + foo(x)
             ^
Error: no overload matches 'Int32#+' with type (Int32 | Nil)

Overloads are:
 - Int32#+(other : Int8)
 - Int32#+(other : Int16)
 - Int32#+(other : Int32)
 - Int32#+(other : Int64)
 - Int32#+(other : Int128)
 - Int32#+(other : UInt8)
 - Int32#+(other : UInt16)
 - Int32#+(other : UInt32)
 - Int32#+(other : UInt64)
 - Int32#+(other : UInt128)
 - Int32#+(other : Float32)
 - Int32#+(other : Float64)
 - Number#+()
Couldn't find overloads for these types:
 - Int32#+(Nil)
Enter fullscreen mode Exit fullscreen mode

So, let's fix that!

  def foo(x)
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    else
      "4"
    end
  end
Enter fullscreen mode Exit fullscreen mode

Now, when it is run with Ruby, it works!

❯ ruby a.rb
b: 4
d: 44
Enter fullscreen mode Exit fullscreen mode

Oh, wait.

"Works" might be open to interpretation there. No error was thrown, but the developer probably wanted d: 8 to be output.

What happens if we ask Crystal to compile this code?

❯ crystal run a.rb
Showing last frame. Use --error-trace for full trace.

In a.rb:16:12

 16 | foo(x) + foo(x)
             ^
Error: no overload matches 'Int32#+' with type (Int32 | String)
Enter fullscreen mode Exit fullscreen mode

Crystal's compiler can figure out that the #foo method can return both an Integer and a String, and it also knows that you can not add an Integer and a String, so it complains right up front that something is wrong.

So, how can static typing in Ruby help to prevent runtime errors like this before they get to production?

Using Ruby Type Checking Tools

Ruby 3.0's type checking system is optional, and to get the best benefit from it, one should install a few gems. I recommend:

gem install rbs
gem install typeprof
gem install steep
Enter fullscreen mode Exit fullscreen mode

The rbs gem is the basic tool for type handling and code introspection with Ruby 3, and it has many more capabilities than are going to be discussed here. The other tools are additional powerful tools that pair well with rbs.

Type signatures for Ruby code are written in a separate file from the Ruby code itself. The common convention is to put them in a sig/ subdirectory next to the code, though this is not an enforced convention.

If you want to get a pre-generated type specification for existing code, you can use a couple of the above tools to do that for you.

The quickest way is to use rbs prototype rb a.rb:

❯ rbs prototype rb a.rb
class Foo
  def foo: (untyped x) -> untyped

  def double: (untyped x) -> untyped
end
Enter fullscreen mode Exit fullscreen mode

It produces some interesting output. This command will run very quickly, even over a very large codebase, but it doesn't do any actual code introspection in order to really figure out what is being passed when methods are called, nor what they are returning. But it does give us a starting point.

The steep gem and command that was installed can use this to check our code for compliance with the static typing. A few of the steps are being elided for brevity, but the nutshell is that one must first do a steep init in the directory containing one's code, and then edit the generated Steepfile so that it looks at the right code and the right type definitions.

❯ steep check
# Type checking files:

..........................................................

No type error detected. 🫖
Enter fullscreen mode Exit fullscreen mode

So, wait. Does Steep say that there are no type-related errors here? Is something wrong?

Only Accurate Type Signatures Are Useful

The problem is that the generated type signature isn't actually useful right at first. The untyped declarations all actually mean any type. Thus, it doesn't tell Steep anything about what is being passed in our expected from the methods.

If you look at the two methods, though, you should be able to see what is expected:

  def foo(x)
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    else
      "4"
    end
  end

  def double(x)
    foo(x) + foo(x)
  end
Enter fullscreen mode Exit fullscreen mode

The #foo method expects a String, and it returns an Integer, and the #double method also expects a String, and it also returns an Integer.

So, let's try editing the type definitions:

class Foo
  def foo: (String x) -> Integer

  def double: (String x) -> Integer
end
Enter fullscreen mode Exit fullscreen mode

Now, when steep is ran, things are much more exciting:

❯ steep check
# Type checking files:

........................................................F.

a.rb:2:2: [error] Cannot allow method body have type `(::Integer | ::String)` because declared as type `::Integer`
│   (::Integer | ::String) <: ::Integer
│     ::String <: ::Integer
│       ::Object <: ::Integer
│         ::BasicObject <: ::Integer
│
│ Diagnostic ID: Ruby::MethodBodyTypeMismatch
│
└   def foo(x)
    ~~~~~~~~~~

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

Remember that this code will run without errors if Ruby is asked to run it, but when the static typing is checked, it has an error.

This error means that the #foo method has been declared to return only an Integer, but it in fact returns either an Integer or a String.

It is worth noting, here, that Crystal provided type-based errors on this code without any type declarations at all. That is because the Crystal compiler does something called Type Inference, where it attempts to figure out the types of things that are not specified, based on how they are referenced elsewhere.

We could do something very similar to the Ruby type declarations in Crystal, except that Crystal allows the type declarations to live within the code itself, instead of a separate file, so it would look like this:

class Foo
  def foo(x : String) : Int32
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    else
      "4"
    end
  end

  def double(x : String) : Int32
    foo(x) + foo(x)
  end
end

f = Foo.new
puts "b: #{f.double("b")}"
puts "d: #{f.double("d")}"
Enter fullscreen mode Exit fullscreen mode

This looks similar enough to the Ruby style type declarations to be easily interpreted, except that it is directly attached to the code. Once this is done, Crystal, too, provides a more specific error:

❯ crystal run a.cr
Showing last frame. Use --error-trace for full trace.

In a.cr:2:25

 2 | def foo(x : String) : Int32
                           ^----
Error: method Foo#foo must return Int32 but it is returning (Int32 | String)
Enter fullscreen mode Exit fullscreen mode

While the wording is different from the error returned by Steep, it means the same thing.

At this point, I know that there is a problem with my Ruby code. Either I have made a mistake in specifying the types that are expected from my methods, in which case I need to study the code so that I understand it better, and then write correct type declarations. Maybe it just needs better type declarations that can handle all of the options:

class Foo
  def foo: (String x) -> (Integer | String)

  def double: (String x) -> (Integer | String)
end
Enter fullscreen mode Exit fullscreen mode

Upon checking it, though:

❯ steep check
# Type checking files:

.........................................................F

a.rb:16:4: [error] Cannot find compatible overloading of method `+` of type `(::Integer | ::String)`
│ Method types:
│   def +: ((::Integer & ::string)) -> (::Integer | ::String)
│        | ((::Float & ::string)) -> (::Float | ::String)
│        | ((::Rational & ::string)) -> (::Rational | ::String)
│        | ((::Complex & ::string)) -> (::Complex | ::String)
│
│ Diagnostic ID: Ruby::UnresolvedOverloading
│
└     foo(x) + foo(x)
      ~~~~~~~~~~~~~~~

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

At this point, a developer should be looking at this, and it should be clear that this is really the wrong approach. In fact, the code in #foo that returns a String instead of an Integer is a bug. That method should look like this:

  def foo(x)
    case x
    when "a"
      1
    when "b"
      2
    when "c"
      3
    else
      4
    end
  end
Enter fullscreen mode Exit fullscreen mode

And the type definition should look like this:

class Foo
  def foo: (String x) -> Integer

  def double: (String x) -> Integer
end
Enter fullscreen mode Exit fullscreen mode

The end result:

❯ steep check
# Type checking files:

..........................................................

No type error detected. 🫖
❯ ruby a.rb
b: 4
d: 8
❯ crystal a.rb
b: 4
d: 8
Enter fullscreen mode Exit fullscreen mode

Steep says that it is correct. Running it with Ruby gives the expected results, and running it with statically typed, compiled Crystal also returns the expected results.

The Takeaway

This example is an extreme distillation of a real-world scenario that I encountered. I had some code with a case statement in it that ran just fine, at least most of the time.

When a type signature was applied to the code that reflected what I intended for the method to be doing, and static type checking was done on the code, I discovered that I actually had a bug in the code, because I had absent-mindedly set the bailout value in the case statement to the wrong type.

Without the option to leverage static type checking to validate my code, I may not have discovered the bug before it crashed production. Once the type signatures were in place, however, the error was obvious.

Embrace Static Type Checking!

I have become a believer in the power of static type checking to improve the overall quality of my code, and I hope that more people embrace the power of it in combination with their Ruby code.

And Oh, By The Way, It's Easier Than I Made It Look

Recall at the beginning of this article, that I mentioned three tools, but during the course of the article, we only used two of them.

The one that we did not use, typeprof, makes it a lot easier to get real, usable type signatures built very quickly for your existing code.

❯ typeprof abc.rb
# TypeProf 0.13.0

# Classes
class Foo
  def foo: (String x) -> Integer
  def double: (String x) -> Integer
end
Enter fullscreen mode Exit fullscreen mode

Typeprof does actual code introspection to understand what the code is doing, what it expects, and what it is returning, in order to create immediately useful type signatures. There are still some scenarios in which it can not magically determine the right typing information, just because Ruby is such a dynamic language, but on even large, complex bodies of code, it does an admirable job of getting pretty close.

Use Ruby 3. Use rbs. Use typeprof, and use steep. Create type signatures for your code, and leverage them to ensure that you are writing less buggy code. There really is very little downside for any code that isn't a true one-off throw-away code.


I stream on Twitch for The Relicans. Stop by and follow me at https://www.twitch.tv/wyhaines, and feel free to drop in any time. In addition to whatever I happen to be working on that day, I'm always happy to field questions or to talk about anything that I may have written.

Discussion (0)

pic
Editor guide