Why I Stopped Using Sorbet in All My Ruby Projects
And why I think you should stop using it too
For over five years, in a professional capacity, I’ve worked mostly on projects built using Ruby. Most of the systems I worked on used Ruby on Rails and varied in size from microservice to monolith.
For several of those years, I’d repeatedly get frustrated with Ruby’s dynamic nature, especially in the decade-old monolith. I’d end up debugging a problem, trying to understand what objects are being passed into a method, and finding myself having to jump through many classes to work it out or execute the code and use a debugger.
Neither of these is particularly enjoyable, so when Sorbet was open sourced by Sorbet in 2019, I couldn’t wait to give it a go. On the face of it, it was the answer to several frustrations I had working with Ruby. Suddenly, we could have types for method arguments, which would be statically checked by Sorbet.
However, after trialing it in several projects, my teams and I removed it entirely from our codebases. In this article, I’ll delve into why.
What is Sorbet?
For anyone not familiar, Sorbet is a type checker for Ruby. By default, Ruby has no type-checking, so you could write something like this:
def add_numbers(a, b)
a + b
end
add_numbers('1', 2)And if you were to execute that, you’d get a lovely error, as you can’t add a string to an integer (unless you’re in JavaScript, and then you technically can):
`+': no implicit conversion of Integer into String (TypeError)To get that error, though, you have to run the code. You could commit and deploy that code, and if your tests were particularly bad, it would make it to production. Even if your tests did catch it, running the code in a more complex codebase can be time-consuming, and failing early saves you a lot of time in the long run.
With Sorbet, however, you can define types for method signatures, like so:
sig {params(a: Integer, b: Integer).returns(Integer)}
def add_numbers(a, b)
a + b
end
add_numbers('1', 2)If you then ran Sorbet’s type checker, you’d see the following error:
editor.rb:9: Expected Integer but found String("1") for argument a
9 |add_numbers('1', 2)
^^^Pretty cool, right? It highlighted exactly what the problem was, and we didn’t even need to run our code. If you have IDE support for Sorbet, it’ll highlight it in your editor, saving you from manually running the type checker.
This is why Sorbet got me excited, I loved working with typed languages such as C#, and now I could have type checking in Ruby, thanks to Sorbet. So, where did it all go wrong?
My Problems With Sorbet
I’ll break down the problems I encountered with Sorbet one by one. Before I do, I want to reiterate that I think the problem Sorbet is attempting to solve is real. I think that Sorbet, in my experience, just doesn’t quite hit the mark.
It definitely achieves the goal of providing type checking in Ruby, but this brought other problems that I believe result in a net loss in productivity vs. not having Sorbet at all.
Code becomes bloated
The majority of engineers will agree that the readability of the code is of paramount importance in a codebase. On top of that, we want to keep bloat to a minimum to aid that goal of readability.
Languages that are statically typed add very little bloat when defining types. Take this C# code, for example:
public struct Coords
{
public int x, y;
public Coords(int p1, int p2)
{
x = p1;
y = p2;
}
}In this example, the type declarations haven’t really added any bloat, and the code is perfectly readable. Let’s write the same code, but in Ruby with Sorbet:
class Coords
extend T::Sig
sig {returns(Integer)}
attr_accessor :x, :y
sig {params(x: Integer, y: Integer).void}
def initialize(x, y)
@x = x
@y = y
end
endIt is not totally unreadable, but it has added quite a bit of fluff to the file. I find reading the types in a language with built-in type support effortless, versus Sorbet, I find it more difficult.
Keep in mind, this is a very small class. If you have attributes that are different types, which is common, each attribute needs to be grouped by its type. So if x and y were different types, you’d have double the amount of code.
Similarly, for methods, there are none apart from the initializer. In a lot of classes, perhaps a model or service, you might have 5+ methods, all with their own signatures. If you combine that with longer types (e.g., your own classes that will be namespaced), you sometimes have to break the parameter definitions into multiple lines, making it even less readable. In some cases, the method signatures became longer than the methods themselves!
Dependencies become a nightmare
Any application you build, big or small, will have some form of dependencies. Whether that be the framework it’s built on or additional ones to provide key functionality, you will have something.
As an application grows in size, so will its dependencies. In Ruby, these come in the form of gems. The majority of gems don’t have Sorbet types, though, which means you need to generate or infer their types using something like Tapioca.
It will read the gems included in your project and try to generate definitions for their constants and methods. This is the best effort, though, and sometimes it won’t be able to generate definitions.
On top of that, we really struggled with conflicting versions between Sorbet, Tapioca, and other gems. Engineers would spend hours solving issues trying to bundle with newer versions of gems.
It got so bad we even wrote our own bash script to try and improve the situation, which did smooth out some of the issues, but ultimately didn’t solve the dependency problems in all cases. This resulted in a lot of lost time debugging issues. Plus, you don’t get full-type checking, as it’s impossible to generate definitions for all gems unless you do it manually.
It’s hard to get engineers’ buy-in
The final one I want to touch on is getting buy-in for Sorbet. Ruby is steeped in history; it was released in the mid-90s, so there are a lot of engineers with decades of experience with Ruby.
On top of that, a lot of boot camps and training often use Ruby as a starter language due to its easy-to-read syntax and ease of picking up. For anyone used to working with Vanilla Ruby, Sorbet is pretty jarring, and they need a compelling reason to change their development flow.
If it were seamless, and the issues above didn’t expect, I expect there would have been a lot more buy-in, but unfortunately, there wasn’t. This meant on the main monolith, a small portion of the code was type checked, but the vast majority wasn’t, and new code was often added without types.
We could have enforced type definitions being mandatory on new code, but as we were trialing Sorbet, we didn’t want to mandate its use before we’d validated it had a positive impact.
To ensure we gave Sorbet a fair go, we also implemented it on a much smaller service and added type definitions to 100% of the code. This was better than the partial adoption in the monolith, and you could see it catch potential issues earlier on, but the two issues above remained.
When talking to engineers, they wouldn’t find Sorbet was working for them. They felt it was working against them. To me, that’s a sign that Sorbet wasn’t for us.
Ruby 3 and What Almost Was
I couldn’t write an article about Sorbet and not touch on Ruby 3. Sorbet was released when Ruby 3 was still in active development, and Ruby 2 was the current major version being used.
Ruby 3 was slated to add type annotations, which would have removed the need for a lot of what Sorbet was doing. Perhaps it wouldn’t have shipped with a type checker, but at least the annotations would’ve been clearer than Sorbet’s signatures, and there would’ve been no need for the definition files Sorbet introduces (also removing the issue with dependencies!).
Unfortunately, in the end, Ruby 3 shipped with RBS. This allows you to describe the structure of your code, similar to Sorbet’s signatures, but in a separate file. You can think of them like interfaces, except your executed code has no knowledge of these interfaces at runtime by default.
You’d write your code as normal in a .rb file, and then optionally define a .rbs file where you define the types. That’s it, though, there is no type checker natively shipped with Ruby, and you could write completely wrong types in the RBS file, and nothing would go wrong. You’d still need a type checker, such as Sorbet.
I truly think Ruby dropped the ball on this one, and it seems largely down to Matz’ (creator of Ruby) strong opinions on putting types in Ruby files. This resulted in type support in 3 being largely descoped, which I think will bite the language in the long run, as I believe typed languages will only grow in popularity, as they have been for many years now.
Summary
That rounds out my overview of my experience with Sorbet and why I wouldn’t personally use it in its current form in any projects going forward.
I’d love to hear other opinions, though. I know Stripe naturally finds it helpful and uses it extensively, and I believe Shopify uses it too. Perhaps they’ve solved these issues or accepted the trade-off of readability for type checking? As with most things in programming, there’s nearly always a trade-off.






