Joel Drapper

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:

  1. it still feels like it’s not part of the method definition
  2. the #: followed by a single space makes it looks visually misaligned next to a regular comment
  3. 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.