Types in Ruby 3: New Features Explained
Even though Ruby is the go-to for many start-ups, it still hasn’t been as powerful as its counterparts in areas such as linting, type validation, etc. This, however, is not true for long, as the latest update of the language has brought a foundation for types, which has been attracting a lot of attention from folks who prefer stricter type-checked languages, such as TypeScript and Java. Building upon the solutions that have existed up to now and unifying the efforts of communities and corporations, Ruby 3 is all set to have its typing system.
What difference will it make? How does it unify the efforts made till now? Does this add another item to the list of items you should know as a Rubyist? Let’s find out!
Here’s what we’ll cover:
- RBS: Ruby 3’s New Typing System
- How RBS Was Designed to Accommodate Existing Ruby Code
- Other New Features
- Dynamic vs. Static Typing
- Is Ruby Statically Typed?
- Ruby Types: Why are Types Advantageous?
- Protection Against Runtime Errors
- IDE Support
- Assisted Duck Typing
- Confidence in Refactoring
- Improved Documentation
- Lesser Mental Load on Developers
- Will Sorbet become Obsolete?
- What’s on the Horizon?
Ruby just got a new typing system - RBS. Just like you, we can’t wait to explore the wide range of possibilities that it brings. Read along as we dive deeper into one of the striking features of the latest update to the Ruby language!
RBS: Ruby 3’s New Typing System
RBS roughly stands for Ruby Signatures. It is a common standard for declaring types that the Ruby team has been talking about for so long. RBS aims at unifying the efforts of the community and third-party solutions up to this point, to provide a common set of rules to define types. This move, however, does not enforce any type-checking solutions, which means that you are free to choose your type checker as long as it supports RBS files.
The Ruby team has kept a lot of things in mind while charting out this addition. The focus has been on backward compatibility, a vision for a uniform standard, as well as an aim for a foolproof static typing solution.
How RBS Was Designed to Accommodate Existing Ruby Code
Ruby 3 came with a lot of notes emphasizing backward compatibility specifically. The team wanted to make groundbreaking improvements, but not at the cost of forcing developers to shift completely to the new version to use those benefits. The current version of RBS has been built with complete support for old versions of Ruby.
An important factor in enabling backward compatibility has been the decision to keep type declarations separate from the main code. While this has been tagged as a not so convenient alternative by some, it is in the best interest of all those who are willing to try types out without having to completely upgrade to the latest version of the language.
Let’s now take a look at a few key features that have been introduced with RBS.
A complex programming paradigm that is often not supported by statically typed languages is Duck typing. Duck typing is a coding pattern in which an object belonging to a certain group is assumed to respond to a certain set of methods. This pattern is aimed at removing strongly bound restrictions over object type and making classes more fluid. Here’s an example of duck typing in Ruby:
# Define a dog class class Dog def walk puts 'Dog walked' end def eat puts 'Dog ate' end end # Define a cat class class Cat def walk puts 'Cat walked' end def eat puts 'Cat ate' end end # Define a driver class class PetActions # A common list for all pets attr_reader :pets # Initialize the list with a dog and a cat def initialize @pets =  dog = Dog.new() cat = Cat.new() @pets.push(dog) @pets.push(cat) end # Define a modified walk for the driver class, which calls walk on each item in the pets list def walk pets.each do | pet | pet.walk end end # Define a modified eat for the driver class, which calls eat on each item in the pets list def eat pets.each do | pet | pet.eat end end end # Initialise your driver code and call the two methods action = PetActions.new() action.walk action.eat
The output for the above code will be:
Dog walked Cat walked Dog ate Cat ate
Notice that we never called walk or eat on the dog or cat object explicitly. But Ruby was still able to figure out and invoke the correct methods for us. This shifts the focus from the type of the object (in this case, either a dog or a cat) to its abilities or properties (in this case, walking or eating). Duck typing works on the ideology that if an object walks and talks like a duck, then the interpreter is happy to treat it as a duck.
But as we mentioned already, this paradigm is not supported by statically typed languages, as it defies the concept of static typing altogether. Yet, RBS has retained this awesome feature by allowing type declarations on interfaces:
interface _Pet def walk: () -> void def eat: () -> void end
This declaration states that anything that can walk or eat comes under the Pet category and can be handled similarly. This is how RBS can be used to accommodate Duck typing while still keeping the rest of the system strictly typed. Also, this method tends to be more accessible for developers, as it uses an interface to loosely outline the Duck typing relation, allowing IDEs to parse it and expose it as an important piece of documentation.
Non-uniformity is another pattern that provides freedom in writing code. It allows you to store instances of two different classes in the same local variable, define a heterogeneous collection of objects, and much more.
Here’s how you can define multiple possible types for a reference:
class GroupMessage # A message can be sent by a user or someone from the moderation team def getSender: () -> (User | Moderator) # This is how you can define a heterogeneous list to be returned from the method def getViewers: () -> (Array[User | Moderator]) end
This has been made possible by the Union types support in RBS.
Other New Features
Apart from the key features discussed above, there are a lot of smaller, yet crucial features in RBS that make it stand out from the crowd of type checkers in Ruby.
Defining types for overloaded methods can be a nightmare. Without RBS, you’d have to define types for each overloaded variant of the method. But here’s what you can do right now.
If you have three overloaded methods - send(String message), send(Integer message) and send(File message), here’s how you can define types for them:
class Message def send: (String) -> void | (File) -> void | (Integer) -> void end
This is all thanks to the support for method overloading in RBS.
While RBS is being marketed as a type definition foundation, it comes bootstrapped with a test suite, which can be used to run type tests without using any external type checking solutions. Even though this makes RBS a complete tool, there is a lot of work that still needs to be done on the type checking front to make the experience smoother for developers. Solutions such as Sorbet and Steep will still dominate the market for a long time until the Ruby team sets down to improve type checking (and not just defining) in RBS.
A bonus with the RBS gem is its CLI. The RBS CLI is prepacked with several standard methods that are meant to make type declaration and checking smoother for developers. Some popular utilities of the CLI are:
- rbs list - Used to print out a list of the types in the current environment.
- rbs validate - Used to validate the syntax of RBS files and carry out some basic semantic checks
- rbs prototype - Used to generate a skeleton-type signature to help you get started, it can do this either from your code or from an already existing RBI type signature.
- rbs parse - Used to parse an RBS file and print syntax errors
- rbs test - Used to run your tests with the above-mentioned test hooks injected
Dynamic vs. Static Typing
There are two major kinds of typed programming languages - dynamic and static. Dynamically typed programming languages are those which perform type checking at run-time. This makes development super easy, as you can use paradigms such as Duck Typing to write code without worrying about failing linters and pre-commit hooks. On the other hand, this leaves the responsibility of keeping the code bug-free in your hands, which is often a price too big to pay for such freedom. Any silly errors can easily slip past the compiler and crash the program during run-time.
Statically typed languages, on the other hand, are way stricter than dynamic ones. They do not allow a single type-error to pass through the compilation phase, thereby reducing a lot of otherwise silly bugs. A statically typed language such as Java will always make sure that the types of the objects concerned are compatible with each other before they can enter runtime, where they can get into some action. But they are costlier to write in terms of both time and skill.
Talking about the future, it looks like the trend of moving towards statically typed languages is here to stay. With giants like Microsoft backing prominent type-checked variants such as TypeScript, it seems like more and more corporations are planning to shift to statically-typed programming environments. The transition is slow though, and it is safe to say that dynamically typed environments will still be around for a very long time.
Is Ruby Statically Typed?
One of the key factors that make Ruby a very popular and fast alternative for creating prototypes is that it is dynamically typed. This means that it is a lot more convenient for writing code. In use-cases where your product is in a very initial stage and is continuously evolving, with new features coming in every week, and a chunk of things being scrapped frequently, Ruby is a great fit. With lesser code and constraints, it is easier to make changes and restructure features.
But this is only appropriate while your product is in development. Once it’s ready, you would want the runtime to be fast and versatile enough to automatically reduce as many bugs as possible. While dynamic typing in Ruby gives you all the freedom to write code in any way that you like, it takes away the power from your IDE. Linting in Ruby is scarce, compared to its statically typed competitors such as TypeScript or Java. Compile-time debugging is a dream, while testing is a nightmare. Seeing all of this, there have been a few attempts at introducing types in Ruby. Steep and Sorbet have emerged as the front-runners of this race, but both have lacked a crucial thing – uniformity. With the latest RBS, the creators have tried to unify these attempts and provide a common ground for developers to define types, all while still being able to utilize other prominent solutions for checking.
With the introduction of RBS, Ruby is in a transition towards a safer programming alternative. Although still not completely static-typed, Ruby will now house better support for linting and in-IDE debugging. It is important to note that types were available in the language before RBS as well. However, they all came with their own set of rules. The way that RBS now helps is by providing a common ground for them to build upon.
Ruby Types: Why are Types Advantageous?
Now that we have built a strong understanding of the latest type checking standard introduced in Ruby, as well as the various types of languages based on their type-checking systems, let’s analyze the various advantages that this new addition to the language brings.
Protection Against Runtime Errors
One of the biggest reasons why you should have types defined in your code is to have lesser runtime bugs. As mentioned previously too, type checking allows languages to run a compile-time check for use of incompatible data and references. Such incidents can be identified right away, and a lot of head-scratching over runtime bugs can be easily avoided with the help of types.
Let us take an example to understand this better. Let’s say there’s a method called add which multiplies two numbers and returns a product. In type-less Ruby, this is how you would define it:
def prod(a, b) a * b end
You’d normally use it to calculate products of two numbers like so:
prod(42, 21) # => 882
But what if you passed in a string accidentally?
prod(21, "apm") # => TypeError (String can't be coerced into Integer)
This happens because internally the * operator is defined to take two numbers as arguments and return a single number as the result. What if this were to happen in a real-time application? As soon as the flow would execute this line, there’d be chaos all around. And before its execution, you’d have no clue, as no compilation step would have pointed this out. Here’s where the static type checking saves the day. If you define the types of the method arguments before execution, you can run a type check to make sure that your code does not go against the rules you’ve laid. Here’s how you can define the signature in the latest RBS syntax:
def prod: (a: Integer, b: Integer) -> Integer
This states that the method prod will take in two arguments, both of which will be integers and return another integer. You can use any type-checking module (eg. Steep, Sorbet) to parse this and run type checks on the code. But after the introduction of RBS, you don’t need any of those for type declarations – you can define types using RBS and test using any of the above modules.
Needless to say, your IDE plays an essential role in determining how fast your development would go. If you have an IDE that can provide enough contextual information about your code, you don’t even need to think twice while writing full-fledged classes. Plugins like Codota exist on the fact that code can be made predictive. Typing helps in this immensely, as typed code is easier to plan, and most IDEs can make great use of it.
With static typing, IDEs can provide better autocomplete suggestions. On-the-fly error reporting is improved, as type incompatibility (a major cause of bugs) can be reported directly by IDEs, now by parsing the RBS files.
Assisted Duck Typing
Duck typing, as we’ve discussed, is an important model used by programmers to imitate realistic object-oriented programming. With support for Duck-typed interfaces, developers have a better idea of what they can do.
Duck typing with types in Ruby 3 is even better, as it allows the concept to remain in its raw form, with no constraints over it, while still empowering plugins and IDEs to provide better functionality and documentation.
Confidence in Refactoring
An important step in the development cycle is revisiting and refactoring code. Refactoring is an activity that is quite likely to introduce new bugs. With safety measures like type checking and in-IDE type-linting in place, developers can be much more confident when going about refactoring (or just playing around with) code, in general.
Software and documentation always go hand in hand. With types being laid out statically, your code is now better documented. This can be quite helpful in making the purpose of each variable and object in your code clear.
Let’s see this in action. Say you had a method foo_method defined as:
def foo_method(a, b) return a + b end
What can you make out of this code? Is it meant to add two numbers? Is it meant to concatenate two strings? Let’s now add an RBS signature for this method:
def foo_method: (a: Integer, b: Integer) -> Integer
Now does this make the situation clearer? Now we have a better idea of foo_method being used to add two numbers. And even if we accidentally use it to concatenate two strings, our type check won’t let it through.
Lesser Mental Load on Developers
With types to follow around, developers do not need to keep a lot of load in their heads. If you have a function with a set of arguments clearly defined somewhere, you don’t need to keep track of it mentally. The types give you an outline to follow while invoking the method and passing in arguments.
About to write a complex method? Write its typed signature first. This way, you’ll have the input and output right in front of you, and then you can fit in the logic, making correct use of the two. When used correctly, type checking can offer assistance that is analogous to Test Driven Development (TDD).
Will Sorbet become Obsolete?
An obvious question in the minds of Ruby users right now is – will other solutions like Sorbet and Steep become obsolete? The answer to this is – no, not at all. Solutions like Sorbet will continue to be used for type checking.
As mentioned before, RBS is only a type definition standard, and not a type checking solution. Once you have defined types for your code in .rbs files, you need a type checking solution like Sorbet, Steep, etc to run tests based on your types.
An important question in the survival of such solutions is whether they are ready to evolve or not. Type checkers such as Steep maintain their type declaration standards (.rbi files), which may be incompatible with the RBS syntax. If they do not choose to provide support for RBS declarations, they might soon be out of use. Luckily, both Steep and Sorbet have been compatible with RBS even before Ruby 3’s official release – so they will, most probably, continue to be the popular alternatives that they are. Other type checkers that do not choose to provide compatibility to RBS files need to worry, as the Ruby team looks all set to improve the new feature based on user feedback and evangelize it as much as possible.
What’s on the Horizon?
With the new typing normal in the game, Ruby seems to be determined to bring type definitions into the mainstream. The industry seems set to move towards statically typed languages, and therefore, the introduction of RBS has happened at a good time for Ruby. While Ruby has always been a dynamically typed language, peeking into the static type realm with the help of third-party solutions like Sorbet and Steep, RBS is going to make things official by enforcing a common foundational standard. Also, as Ruby 3 aimed for 3x improved performance than Ruby 2, the introduction of types is bound to help that case as well. This is so because statically typed languages are known to take off some load from the runtime of a language and put it onto the compile-time type checking, which generally results in a visible performance improvement. It is only a matter of time before we can see the difference RBS makes in the Ruby community.
Looking back at the post, we analyzed Ruby’s latest feature – RBS, and understood its features in detail. We then compared statically typed and dynamically typed languages, before moving on to discuss Ruby’s stance. After that, we looked at the advantages offered by Ruby’s new types and ended by discussing the scope and prospects of other type checking solutions after the new update.