Talks

Gradual typing for Ruby: comparing RBS and RBI/Sorbet

RubyKaigi 2023

00:00:01.800 Thank you for joining this session on gradual typing for Ruby. Today, we will compare RBS (Ruby Signature) and RBI (Ruby Interface) with Sorbet.
00:00:09.960 My name is Alexandre Terrasa, and I am a staff engineer at Shopify. I work on the Ruby and Rails infrastructure team alongside many amazing people.
00:00:15.240 You may have heard of some of the projects we are working on, such as Drive, Ruby YGIT, the Ruby debugger, Ruby LSP, and many more. But let's focus back on the problem of gradual typing in Ruby.
00:00:28.560 Firstly, why do we want to perform type checking? Let's take a very simple example. We have a class 'Cow' that has a constructor taking a name.
00:00:40.920 We have a method 'talk' that is going to display the name. Under good conditions, we can correctly initialize the cow by passing a string for the name and call the talk method, and everything works well. However, under bad conditions, if we pass the wrong argument to the constructor, it will lead to a runtime error. This is something we want to avoid because customers may end up seeing an error page. It is crucial to catch these errors ahead of time, especially in larger codebases.
00:01:11.340 Even with advancements from AI and new language models potentially catching such mistakes, large codebases introduce complexity that makes it more challenging. It’s not just about catching type errors. For instance, if we take this piece of code with a multiplication method, we can pass a string and it multiplies it by two, resulting in an unexpected result. As a library provider, I want to enforce a contract through types, helping users understand what they are allowed or disallowed from doing with my code.
00:01:31.140 Type checking in Ruby isn't a new concept. In December 2015, Jeff Foster from Tufts University released LDL, an early attempt to add types to methods and perform type checking. In June 2019, Stripe released Sorbet, which offered a different type checking approach. At Shopify, we have been using Sorbet since February 2019. More recently, in December 2020, we witnessed the introduction of RBS—a type definition language for Ruby—alongside Sutor and the newer type checker implementations like ABS.
00:01:49.979 While none of these concepts are brand new, Ruby 3 established several key items for gradual typing. The first level concerns the application's code as well as the libraries or gems we rely on. The idea with Ruby 3 is to define type definitions that not only apply to your code but also to the gems being utilized.
00:02:24.180 The first layer of type checking involves tools like TypeProf, which utilizes type definitions from your application's code and the gems it uses to identify type error information and generate additional type definitions. It has the capability to infer types and generate matching signatures.
00:02:41.040 However, it's important to note that the type profiler does not utilize application code's type definitions but rather relies solely on those of the gems for efficiency. Moving onto level 2 type checking, this is where tools like RBS and Sorbet come into play. When discussing RBS or FBI (File-Based Interface), we are looking at this second layer, with RBS providing standardized definitions for type checking.
00:03:03.900 The key goals for gradual typing in Ruby 3 included establishing a standardized language for type definitions, namely RBS, which is both readable and writable for Ruby developers, and independent of Ruby itself. This design allows you to plug in a type profiler or type checker that suits your needs. To meet particular requirements, you might opt for TypeProf, Steep, or Sorbet, among others, facilitating optional source-level type annotations.
00:03:40.620 It's vital to differentiate between the type definition language and the type checker. RBS defines the types and syntax for your application while type checkers like Sorbet implement type safety rules and inline type annotations to identify what constitutes a type error.
00:04:03.419 Let's quickly compare Steep and Sorbet. Both support gradual typing, allowing you to add types progressively, avoiding the need to type everything at once. They also support flow typing, enabling type refinement within blocks of methods. Steep utilizes structural typing, akin to duck typing, while Sorbet employs nominal types, leading to distinctions in their handling of interfaces.
00:04:34.920 Both require inline annotations within application code. Steep reads type definitions in RBS format, while Sorbet takes a different approach, using another format known as RBI. Steep is implemented in Ruby, whereas Sorbet is written in C++. Notably, Sorbet features runtime components for type checking, which we will explore later.
00:05:09.120 To learn more about these tools, I encourage you to check previous talks at RubyKaigi by Yusuke and Jay that delve deeper into Steep and Sorbet. Now, let's examine the syntax of the type definition languages. On the left, we have RBS syntax for defining instance variable types and methods. On the right, we can see the equivalent in RBI syntax.
00:05:50.640 The significant difference is that RBS operates as an external domain-specific language, meaning its syntax diverges from Ruby. Writing in RBS does not utilize standard Ruby syntax, making it more expressive. However, because it is a separate language, it requires additional tooling, limiting support like syntax highlighting in platforms such as GitHub.
00:06:15.240 In contrast, RBI is an internal domain-specific language where syntax aligns with Ruby. While less expressive due to constraints from Ruby, the trade-off is the availability of existing Ruby tools like formatters, linters, and syntax highlighting.
00:06:41.640 Both Steep and Sorbet require inline annotations in Ruby code. Steep uses comments for annotations, which do not interfere with program execution. However, this limits the placement of type hints to the end of lines, while Sorbet relies on runtime type information, necessitating importing Sorbet runtime for the application to understand static annotations.
00:07:14.760 At Shopify, we've transitioned to Sorbet since January 2019, with 98% of our files typed and 61% of methods possessing signatures. When we surveyed developers, many expressed a desire for more typed code, citing confidence from type safety and the tooling that supports it. The results showed almost 80% of surveyed developers wanting more types present in our codebase.
00:08:00.900 However, there was a notable disagreement regarding the type definition syntax—particularly with RBS. The syntax does not always feel familiar to Rubyists, which raises the question of whether we can migrate effectively.
00:08:37.560 While Steep can process RBS, it does not perform as well with larger monolithic projects. For instance, where Sorbet executes type checks within 15 seconds, Steep can take around 45 minutes, making it impractical for CI.
00:09:08.580 Furthermore, another concern lies in type safety. If we apply substitution principles incorrectly in Steep, it can lead to runtime issues not being captured correctly by the type checker. This discrepancy is crucial for maintaining safe typing standards at Shopify.
00:09:46.020 Additionally, because RBS operates as a separate file system, it leads to duplication—requiring thousands of new RBS files to represent the existing classes and methods, which adds unnecessary complexity. In contrast, we can avoid such duplication with Sorbet by integrating type definitions within the same Ruby code.
00:10:29.040 In a desire to utilize RBS and reap its syntax benefits while maintaining the performance of Sorbet, we conducted an experiment by creating an RBS to RBI translator in Sorbet.
00:10:56.160 This would translate RBS definitions on-the-fly. Although we initiated a pull request, we eventually closed it due to complications related to maintaining the translator alongside RBS updates.
00:11:31.440 Consequently, we shifted our focus to an existing solution called Tapioca, developed by my colleague Emily. This tool generates RBI files ahead of time, allowing us to translate RBS files into RBI once, rather than for every type check.
00:12:05.640 Looking at the features of RBS and RBI, both possess comparable capabilities around type declaration for Ruby constructs. For example, instance methods, singleton methods, and module functions are all supported.
00:12:47.640 Yet, while you cannot define instance variables directly in RBI like you can in RBS, you can use a slightly different method in your Ruby code to assign types. Both systems support generics, even though there may be differences in implementation.
00:13:43.680 For example, with generics, both allow constraining types. In a rough example with a 'Pen' class, I can ensure only animals of a specific subtype are permitted within, preventing unwanted types, like a wolf, from being added to a collection of cows.
00:14:40.980 Ultimately, while the lines between RBS and RBI have many similarities, certain nuances exist, especially regarding constraints and type parameters. RBS outshines with its more comprehensive syntax for generic bounds, while RBI remains largely functional.
00:15:46.680 Another topic is abstract classes and methods. RBS currently lacks certain features, like abstract classes, that allow an encapsulated design approach where methods that require overrides can't be instantiated directly, thus maintaining proper object-oriented design.
00:17:05.640 In contrast, techniques such as requiring ancestor types in Sorbet can prevent errors from uncoupled or misaligned mixins, ensuring that they appropriately apply only to the correct classes.
00:17:44.880 For dynamic typing, Yusuke's presentation highlighted the power of type annotations. A critical consideration for inline type annotations is what to do when defining casts or using inline type casting within Ruby code.
00:18:36.300 While various strategies are available, the future enhancement may involve integrating core Ruby features to aid in type checking and annotation definition, in ways that do not disrupt Ruby’s typical syntax.
00:19:12.420 Yet, with the diverse type checkers available, we will also need to standardize methods for consistent type annotations going forward. To summarize, RBS is set to become the mainstay for type definition in Ruby, and collaborative pathways to utilize it with Sorbet effectively are achievable.
00:20:00.880 Although the generics pose a challenge and there are definitely missing features, with a growth in type checkers, there will soon be a need for a canonical approach to typing principles in Ruby.
00:20:46.640 I appreciate your time and attention during this session. Thank you very much!