The journey to the perfect type signature in Ruby
I think runtime type checking is the most pragmatic approach to typing Ruby and in many ways it’s superior to static type checking. I’ll write more about that another day.
Literal helps you add runtime type checking at object boundaries. Its prop
method generates the initialiser and optional reader/writer methods.
For a while now I’ve wanted to extend Literal with type signatures and I’ve recently made progress on this.
So today, I want to explore this design problem. How do you add types to a language that wasn’t designed with types in mind? And how do you make it feel native?
Let’s begin by looking at some other examples:
Sorbet’s type signature
Sorbet is a static type checker for Ruby and it provides a way to write type signatures by calling the sig
macro right before the method definition.
sig { params(name: String).returns(String) }
def say_hello(name:)
"Hello #{name}!"
end
This gives you everything you need and it’s right there next to your code. It’s also real Ruby code so it gets syntax highlighting. But I rank it an F for aesthetics.
The signature doesn’t feel like it’s part of the method definition. It’s also quite verbose. .returns
feels particularly awkward.
I don’t know if Sorbet needs to be able to execute this block at runtime. If the signatures are read statically, they could have used the pattern matching syntax in the sig
block for the return type.
sig { params(name: String) => String }
This would be an improvement, but I wouldn’t consider it aesthetically pleasing.
RBS
The same signature in RBS would look like this.
def say_hello: (name: String) -> String
Aesthetically, this is pretty good. But you need to write the signature in a separate file and define a regular method in your Ruby file.
For that, this goes in C-tier.
RBS Inline
There’s another way to write RBS method signatures. You can put them in a special comment just above the implementation. I like this better. VSCode will even do syntax highlighting in these comments.
#: (name: String) -> String
def say_hello(name:)
"Hello #{name}!"
end
What I don’t like about it:
- it still feels like it’s not part of the method definition
- the
#:
followed by a single space makes it looks visually misaligned next to a regular comment - it’s a comment
This is B-tier.
YARD
RBS isn’t the first to do method signatures in comments. YARD uses magic comments to define method signatures for documentation.
# @param [String] name
# @return [String]
def say_hello(name:)
"Hello #{name}!"
end
This is F-tier. It’s extremely verbose, it doesn’t feel like part of the method definition, you have to use a new line for each parameter and what’s with the square brackets that make it look like an array or tuple generic?
Early attempt #1
I had the crazy idea that you could write the signature at the end of the definition instead of the beginning.
sig def say_hello(name:)
"Hello #{name}!"
end, [String] => String
The sig
method here receives the symbol from the definition, and it receives the parameters as a keyword argument where the key is an array and the value is the return type.
I used an array here so you could pass position or keyword arguments, e.g.
[String, Integer, foo: Enumerable] => String
But the square brackets make this look like some tuple type, and having the signature at the end is ugly.
F-tier.
Early attempt #2
Another approach was pitched, inspired by Sorbet. It used a call to sig
with the type signature given as keyword arguments.
sig name: String
def say_hello(name:)
"Hello #{name}!"
end
There’s a lot to like about this. It’s right next to the definition and it’s syntax highlighted. But it still doesn’t feel like part of the method definition, and there was no plan for the return type.
D-tier.
Pre-processing design #1
Stephen recently called me on Tuple and pitched a different idea: Let’s use the same pre-processing techniques from StrictIvars to add type signatures that feel native. We’d use real Ruby syntax, but pre-process it with different semantics.
A pre-processor transforms the code into different code when the file is required, right before Ruby interprets it.
typed def say_hello(name: String)
"Hello #{name}!"
end
This looks really nice, but it doesn’t deal with the return type and it prevents you from providing defaults.
B-tier.
Pre-processing design #2
Ruby has an incredibly flexible syntax. After a while, we came up with a completely different tree of valid Ruby that looked just like a method definition.
fun say_hello(name: String) => String {
"Hello #{name}!"
}
Ruby’s parser (Prism) sees this as a call to the method fun
with a keyword argument. The key is the result of calling say_hello
and the value is the result of calling the method String()
with a block.
This is, in my opinion, aesthetically perfect.
I’ve always loved the fun
keyword from Kotlin. It’s distinct from regular method definitions so doesn’t require the typed
modifier. And the return type feels so natural. It also looks amazing with ligatures.
S-tier.
Pre-processing design #3
The one downside with the fun
syntax is that existing tooling for Ruby doesn’t see it as a method. For example, the symbol outline in Zed doesn’t pick these up. So we came up with another idea that does use a real def
.
def say_hello(name: String) = String do
"Hello #{name}!"
end
In this case, we’re using an inline method definition (note the =
). Because we pass a block to the return type, it feels like a native method definition.
While this is valid Ruby, it’s a very uncommon pattern. In fact, I couldn’t find a single example of this in the wild using grep. The distinct pattern means we can skip the typed
modifier, which tightens this up a bit.
I give this an A+. The =
doesn’t look as good as =>
for the return type and the do
feels unnecessary. Despite that, this is the design we decided to go with because it makes the most pragmatic sense.
We’re building the pre-processor in a new gem called Literally. We have a full spec including the ability to define defaults, optionals, splats and keyword splats.