Type Checking

Gradual typing for Ruby: comparing RBS and RBI/Sorbet

Gradual typing for Ruby: comparing RBS and RBI/Sorbet

by Alexandre Terrasa

In this video titled 'Gradual typing for Ruby: comparing RBS and RBI/Sorbet', Alexandre Terrasa, a staff engineer at Shopify, discusses the evolution and benefits of gradual typing in Ruby. The presentation focuses on comparing RBS (Ruby Signature) and RBI (Ruby Interface) with Sorbet, shedding light on type checking methodologies and their implications in programming.

Key points discussed include:

- Importance of Type Checking: Gradual typing helps to avert runtime errors by enforcing type contracts in code, especially in larger codebases. Type errors can lead to significant customer-facing issues, making it essential to catch such mistakes in advance.

- Historical Background: The video highlights previous attempts at type checking in Ruby, including LDL in 2015 and Sorbet released by Stripe in 2019. In 2020, RBS was introduced to provide a standardized type definition language for Ruby.

- Layered Type Checking: Terrasa explains two levels of type checking: using TypeProf for assessing applications and libraries, and RBS/Sorbet for standardized type definitions. Ruby 3 aimed to create a structured ecosystem for type checking by emphasizing a clear definition language (RBS) that could work across various tools.

- RBS vs. RBI: RBS operates as an external domain-specific language (DSL) which is more expressive but requires additional tooling, whereas RBI is an internal DSL that aligns neatly with Ruby's syntax. Both methods support inline annotations yet differ in their integration and performance when used in larger projects.

- Developer Feedback: At Shopify, 98% of code files transitioned to Sorbet, revealing a strong developer demand for type safety which improved their confidence in the codebase. However, there were mixed feelings about the unfamiliar syntax of RBS, particularly for seasoned Ruby developers.

- Type Safety Mechanisms: The presentation discusses divergences in type safety between reflection methods in Steep and runtime checking in Sorbet, showing how they handle object-oriented design principles and method definitions.

- Future Opportunities: The potential for improved inline type annotations with Ruby's core features and standardization across various type checkers were suggested as essential next steps for Ruby's gradual typing journey.

In conclusion, Terrasa emphasizes that while both RBS and Sorbet offer vital features for type checking and have their distinct advantages and drawbacks, there is a growing need to establish standardized methods for typing principles within the Ruby community, especially with the increasing adoption of type checkers like RBS.

Terrasa's session illustrates the dynamic advancements in Ruby's gradual typing landscape and affirms a promising collaboration pathway between RBS and Sorbet, as Ruby continues to evolve.

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!