I must admit – when it comes to programming languages, I have a type: robust type systems!
Ever since learning Elm, I’ve fallen in love with programming with a expressive type system. Since I work in other languages as well which are dynamically typed, I find myself yearning for a more robust type system and the guarantees of a static type checker. So I started exploring options for adding types to some of the languages in which I work most frequently, Ruby and Elixir.
Robust type systems are also becoming increasingly popular – it’s not just me who loves them! Type systems and checking eliminates a whole genre of errors – runtime errors – and it does more than that too: it offers a means of modeling your domain, enforcing contracts and consistency within code, and documenting your code to enhance readability and intent. There are lots of ways that types can benefit a project, and much of the tooling out there lets you gradually add types to your codebase, so you can test out how it’s benefitting you before making a commitment.
As more people have been seeing the value of working with an expressive type system, more tooling has been created for gradually implementing them in dynamically typed languages, and I want to share about what’s emerging in this area.
Note on vocabulary
Before we begin, let’s review some vocabulary.
“Statically typed” means that types are checked at compile time. “Dynamically typed” means that types are checked at runtime. We’ll use these terms to formally categorize languages.
A “type system” is a language’s set of rules that govern its constructs, such as varibles, functions, and components likes strings and integers, or other data structures like maps and lists.
“Strongly typed” or “strictly typed” means that a language’s type system is robust, expressive, and strictly enforced. A “weak” type system by contrast is one which has more permissive rules and which and does not support features for expressing more complex types. “Strong”and “weak” are therefore not formal technical terms, and rather are used to compare different type systems based on how robust they are relative to each other. So we can think of type systems on a range from more primitive, or weak, to more expressive, or strong.
Elixir offers some means of working with types out of the box, which is really cool. Using typespecs and tooling for static analysis, one can mimick many aspects of a static type system. There’s also an exciting new language in development, Gleam, which, not to be reductive, is like Elixir with type checking, and it is pretty awesome.
Elixir offers typespecs as an opt-in feature. Typespecs are used to annotate function signatures (also called specifications), and for defining custom types.
On any Elixir function, you can add a typespec as annotation to state the types of arguments it expects and the type which will be returned. Typespecs are like documentation and they are not evaluated at runtime, so if you make a mistake with them, that won’t affect how the code runs.
Typespecs can also be used to define custom types in Elixir, a feature of stronger type systems.
Typespecs alone are valuable, even if you don’t combine them with other tooling to enforce type checking. I like to think of a type annotation as documentation, and as a contract. When you add a specification, it’s like making a promise that your code does what you’re saying it does. It clarifies intent for other readers of the code. If you use typespecs to define custom types, you can use those types to expressively model your domain.
If you’re interested in the benefits of a type checker, you have some options to layer onto typespecs.
You can enable an IDE to highlight any mismatches between your typespecs and code so you can identify issues as you work (I recommend Visual Studio Code + VSCode Elixir Plugin for this). This plugin will even show you auto-generated annotations to make adding them less work, and you’ll see highlights when you’ve created a mismatch, saving time as you develop.
Typespecs can also be combined with a tool such as Dialyzer to do type checking on your behalf outside of when you run the program, thus mimicking a static type checker.
See the documentation here to go deeper on how to use typespecs, such as mixing them in with guards or using opaque types.
Finally, it’s worth noting that Elixir offers some means of making “contracts” within code beyond type checking, like sum types or Behaviors. These are outside the scope of this article but I encourage you to check out these links if you’re interested in going deeper.
The other option to consider for Elixir is Gleam, an exciting new language in development. Again, not to be reductive, but Gleam is like Elixir + robust static typing. I tried it out and it felt exactly like Elixir, except I could build custom types with ease and rely on the type checker. I did struggle a bit to get my Gleam environment set up because some of the requirements involved different versions of Erlang OTP and rebar than what I had installed already, but I was still able to get going. I have not tried adding Gleam to a Phoenix application but it seems it can be done! I hope to try that out soon.
I think that currently it’s more practical in Elixir to rely on the built in support for typing than it is to switch to Gleam, but I’m excited about where Gleam is going and think it could prove to be a really excellent language.
In Ruby land, there’s a growing suite of tools for type checking around existing Ruby code - Sorbet, RBS, and, excitingly, the upcoming release of Ruby 3 which will ship with support for type checking.
Sorbet is a type checker designed for Ruby and built by Shopify as they needed a way to maintain and scale their very large code base. Sorbet lets you add type checking to your Ruby code one file at a time. I recently tried adding it to a project, and it was easy to set up initially.
One weird thing you run into with Sorbet is that metaprogramming is popular in Ruby and especially so in Rails, where many functions are defined at runtime, and therefore don’t yet exist when type checked ahead of runtime. For cases like this, you can declare types of these methods ahead of time and Sorbet will know to use them. It’s a little counterintuitive to add types for methods that don’t yet exist, but it works.
If you’re looking to add Sorbet to a Rails project, I’d recommend sorbet-rails which offers tooling to help auto-generate types based on patterns in Rails, like column getter methods. It would be a lot of redudant work to add type checking to everything in a Rails project, so this tool thankfully automates that away.
RBS and Ruby 3
RBS is a language for defining types that will be used in Ruby 3, which will ship with support for type annotations. You can also use it independently of Ruby 3. Sorbet will be incorporating RBS as a means of adding type specifications, and you can read about the ongoing development here. Because of this, Sorbet is concentrating on other features while the Ruby team focuses on the RBS side of things. For now, if you’re looking to get going with types in Ruby, I’d recommend Sorbet (and Sorbet Rails) because it has more tooling and documentation available, which will still be relevant when Ruby 3 ships.
Give it a try!
If you’re working in a dynamically typed language, consider if adding a more robust type system and static type checking would benefit your code. For many popular language ecosystems, there is tooling that is emerging for this. It’s been my experience that adding a type system can help get rid of errors, aid in modeling the domain, and clarify the intent of your code for others to read, all which helps to scale and maintain big projects. Importantly, you don’t have to switch over to types all at once, instead opting to gradually add them in. Consider trying it out!