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!