The State of Ruby 3 Typing

Introducing RBS, Ruby’s new type signature language

Reddit
LinkedIn

We're pleased to announce Ruby 3’s new language for type signatures, RBS. One of the long-stated goals for Ruby 3 has been to add type checking tooling. After much discussion with Matz and the Ruby committer team, we decided to take the incremental step of adding a foundational type signature language called “RBS,” which will ship with Ruby 3 along with signatures for the stdlib. RBS command line tooling will also ship with Ruby 3, so you can generate signatures for your own Ruby code.

About the author: Soutaro is a lead Ruby engineer at Square working on Steep, RBS, and static typing. He is a core Ruby committer working with Matz and other core committers on the RBS specification that will ship with Ruby 3.

Background

Static typing versus dynamic typing is an age old issue for programming languages. Statically typed languages are suitable for larger projects but are often less flexible. Dynamically typed languages allow for rapid development, but scaling teams and codebases with them can be difficult.

Programming language designers are aware of these tradeoffs, and try to incorporate features of the other to offset this. C# has a feature called dynamic which delays type checking from compile time to runtime. Assigning and reading any type of value is allowed at compile time but may raise a runtime error to ensure safety. This is almost equivalent to dynamically typed languages! How about the opposite? We see dynamically typed languages implement type checking options (PHP, Python). We also have typed dialects of dynamically typed languages that are used in production (TypeScript).

Matz declared that Ruby 3 will support static type checking four years ago. After seeing multiple community developed type checkers, the Ruby committer team decided to build a foundation for the community to build type checkers on. Ruby 3 will ship with the ability to write type signatures for Ruby programs as well as built-in type signatures for the Ruby standard libraries. The standard type signature language will make type definitions in Ruby code portable between type checkers and encourage the community to write types for their gems and apps.

We call the language and the library RBS.

What does RBS look like?

We defined a new language called RBS for type signatures for Ruby 3. The signatures are written in .rbs files which is different from Ruby code. You can consider the .rbs files are similar to .d.ts files in TypeScript or .h files in C/C++/ObjC. The benefit of having different files is it doesn't require changing Ruby code to start type checking. You can opt-in type checking safely without changing any part of your workflow.

The type signatures for Ruby classes in RBS will look like this.

# sig/merchant.rbs

class Merchant
  attr_reader token: String
  attr_reader name: String
  attr_reader employees: Array[Employee]

  def initialize: (token: String, name: String) -> void

  def each_employee: () { (Employee) -> void } -> void
                   | () -> Enumerator[Employee, void]
end

The merchant.rbs file defines a class called Merchant, and it helps the reader to understand the overview of the class.

The class has three attributes token, name, and employees. The type of token and name are String. RBS also supports generic classes like Array as we can see with the type of employees attribute. It is an Array of Employees.

RBS also describes methods defined in the class and their types. The class defines the initialize and each_employee methods. The initialize method requires token and name as keyword arguments. The each_employee method accepts a block, or it returns an Enumerator instance.

RBS is a language to describe the structure of a Ruby program. It gives developers an overview of the code and what classes and methods are defined. The biggest benefit is that the type definition can be validated against both the implementation and its execution!

Key features in RBS

The development of a type system for a dynamically typed language like Ruby differs from ordinal statically typed languages. There's a lot of Ruby code in the world already, and a type system for Ruby should support as many of them as possible.

This forces type system designers to make compromises on complexity and correctness for compatibility with existing code. We may have to introduce a type checker feature to support a pattern in existing Ruby code that may be incorrect otherwise. However, adding features makes the type system complicated and difficult to understand. So, we have focused on the most important code patterns to minimize the complexity of the type system.

We can show two of the important characteristics of Ruby code and how we can give types for them.

Duck typing

Duck typing is a popular programming style among Rubyists that assumes an object will respond to a certain set of methods. The benefit of duck typing is flexibility. It doesn't require inheritance, mixins, or implement declarations. If an object has a specific method, it works. The problem is that this assumption is hidden in the code, making the code difficult to read at a glance.

To accomodate duck typing we introduced interface types. An interface type represents a set of methods independent from concrete classes and modules.

If we want to define a method which requires a specific set of methods we can write it with interface types.

interface _Appendable
  # Requires `<<` operator which accepts `String` object.
  def <<: (String) -> void
end

# Passing `Array[String]` or `IO` works.
# Passing `TrueClass` or `Integer` doesn't work.
def append: (_Appendable) -> String

This is better than traditional duck typing as it defines an explicit interface a class or module is expected to implement and provides hints for documentation and editor plugins to expose the formerly implicit interface as solid actionable documentation.

Non-uniformity

Non-uniformity is another code pattern of letting an expression have different types of values. It's also popular in Ruby and introduced:

  • When you define a local variable which stores instances of two different classes
  • When you write an heterogeneous collection
  • When you return two different types of value from a method

To accommodate for this RBS allows union types and method overloading.

class Comment
  # A comment can be made by a User or a Bot
  def author: () -> (User | Bot)

  # Two overloads with/without blocks
  def each_reply: () -> Enumerator[Comment, void]
                | { (Comment) -> void } -> void

  ...
end

Union types and method overloading are commonly seen in Ruby code and standard libraries.

Ruby programming with types

We provide a language to write types. So, what can we do with RBS files?

The following is a list of major benefits of having types. We can write types in RBS files, and the tools will help you writing Ruby code by:

  • Finding more bugs: We can detect an undefined method call, an undefined constant reference, and more things a dynamic language might have missed.
  • Nil safety: Type checkers based on RBS have a concept of optional types, a type which allows the value to be nil. Type checkers can check the possibility of an expression to be nil and uncovers undefined method(save!)' for nil:NilClass`.
  • Better IDE integration: Parsing RBS files gives IDEs better understanding of the Ruby code. Method name completions run faster. On-the-fly error reporting detects more problems. Refactoring can be more reliable!
  • Guided duck typing: Interface types can be used for duck typing. It helps the API users understand what they can do more precisely. This is a safer version of duck typing.

Of course none of this comes for free. How are we building tools for RBS to make work easier for developers to start using it?

We developed static type checkers on the top of RBS. Steep is the static type checker implemented in Ruby and it is based on RBS. Sorbet is a static type checker which has its own type definition language called RBI, but has plans to support RBS in the future.

We are also developing and working on additional tools to expand the RBS toolchain. RBS runtime type checker is one of the Ruby Google Summer of Code projects, which uses RBS type signatures to implement runtime type checking. type-profiler is an exploratory project to generate RBS files from Ruby source code based on a program analysis technique called Abstract Interpretation. There is also a project for Rails support.

Sorbet and RBS

Sorbet is the most widely used static type checker for Ruby today. RBS is not trying to deprecate Sorbet and its type signature format RBI. Matz and the Ruby committer team are working closely with the Sorbet team and really appreciate the efforts and developer experience improvements by the Sorbet team.

The goal of RBS is to provide the foundation to describe the type information of Ruby programs. Static type checkers like Sorbet or Steep can use the type definition written in RBS. To achieve interoperability, RBS gem ships with a translator from RBI to RBS, and a translator from RBS to RBI is being developed.

Conclusion

This post introduces RBS, a new part of Ruby 3 for types. I explained what you can write using RBS, the key concepts of the design of RBS, and the benefits and tools that come with RBS. You write type definitions for your Ruby code, and our tools will analyze your code. We know not all of Rubyists will switch to typed Ruby, but we believe that it's worth trying with your code!

At Square, we are testing RBS based type checking solutions and continuing to iterate on them. We are writing RBS files for some internal projects and type checking the code with Steep. We are building RBS generators from .proto files.

I am looking forward to sharing the results of these experiments within a few months.

Updates: