Ruby is a dynamically typed language. You can't enforce types on your method signatures, local variables, or pretty much anything else. And yes, this is precisely what tools like Sorbet and RBS aim to solve — but hear me out before you reach for them.
I've been writing Rails professionally for a while now, and I've come to believe that good naming conventions can get you 80% of the way there. No gems, no type annotations, no build step. Just discipline.
Convention over configuration (yes, even for variable names)
One of the things I love most about Rails is its "convention over configuration" philosophy. We already lean on naming to communicate intent everywhere: model names, table names, route helpers. Why not apply the same rigor to our variables?
Consider a method like this:
def invite_to_party(user)
# ...
endNobody reading this code expects user to be a String or an Integer. We all understand it's a User instance. It feels almost too simple to mention, but think about it: this tiny convention is already doing the job of a type annotation. No tooling required, just a shared understanding baked into the name.
Encoding types in variable names
The same idea scales beyond model instances. Whenever a variable's type isn't obvious from context, you can encode it directly in the name — and _h for hashes is the most common place to start. Let's say you're fetching user attributes from an external JSON API:
response = Faraday.get("https://api.mypartner.com/api/v1/users/17")
user_h = JSON.parse(response.body)
# => { "first_name" => "John", "last_name" => "Doe" }That little _h suffix changes everything. Now, whenever user_h gets passed around your codebase, every developer who touches it knows two things at a glance: it contains user data, and it's a Hash — not a User model instance. No confusion, no need to trace the variable back to its origin.
You could extend this pattern to other types too: user_ids for an array of IDs, user_json for a raw JSON string, and so on. The point is to encode the type in the name so the code documents itself.
Naming foreign keys with intent
This approach really shines with ActiveRecord associations. Take the classic book/author relationship:
class Book < ApplicationRecord
belongs_to :author, foreign_key: :author_id
end
class Author < ApplicationRecord
has_many :books
endStraightforward. But now let's say the author is actually a record in the users table. This is where many codebases start getting confusing — you see author_id and have no idea it points to a User. But if instead you write it a bit more explicitly:
class Book < ApplicationRecord
belongs_to :author_user, foreign_key: :author_user_id, class_name: "User"
endBy embedding the class name into the association and the foreign key, you leave no room for doubt. Your future self — and every developer who inherits this code — immediately knows that author_user_id references the users table.
Naming is your type system
Every few years, the same conversation resurfaces: "Ruby needs static typing. Look at what TypeScript did for JavaScript — it'll save Ruby too." And sure, TypeScript was a game changer for JS. But Ruby isn't JavaScript.
Static typing comes at a cost — in verbosity, in tooling overhead, in fighting the language instead of working with it. And that cost is significantly harder to justify in Ruby, where duck typing and metaprogramming are not edge cases but core design principles. Annotating types on top of a language that was built to be fluid and expressive often means working against the grain.
Good naming conventions won't catch every bug a type checker would. But they're free, they're frictionless, and they work with Ruby rather than trying to turn it into something it's not.
(And if your project eventually grows to the point where Sorbet genuinely makes sense — well, at least your variable names will already be great.)