Type safety in Ruby doesn't require rewriting your code or abandoning dynamic idioms. Since Ruby 3 introduced the RBS language, developers can define shapes and signatures in separate files using native core classes like String, Integer, and Array. This setup powers tools like Steep to catch bugs before production, treating core classes as the bedrock of static analysis.
Understanding how built-in core classes map to RBS definitions transforms legacy codebases into predictable, type-safe environments. Let's look at how these core definitions operate under the hood, how they handle generics, and how you can leverage them to build bulletproof Ruby applications.
Anatomy of RBS Core Signatures
RBS files use a distinct syntax format ending in .rbs to describe classes, modules, and methods. Unlike inline type systems, these declarations live outside your source code. Ruby ships with built-in signatures for its core library and standard library out of the box.
When you define a class in RBS, you map it directly to Ruby's core mechanics. For example, a standard user class interacting with core types looks like this:
class User
attr_reader id: Integer
attr_reader name: String
attr_reader tags: Array[String]
def initialize: (id: Integer, name: String, ?tags: Array[String]) -> void
end
The question mark before ?tags marks the keyword argument as optional, mapping cleanly to Ruby's keyword argument handling. The core type Array[String] uses generic parameters to ensure collection integrity, preventing string-only arrays from accidentally receiving integer payloads.
How Core Classes Handle Generics and Collections
Ruby core collections like Array, Hash, and Set form the backbone of everyday data manipulation. In RBS, these classes are declared as generic types, meaning they accept type variables to constrain what elements they hold.
Take a Hash that maps string keys to mixed values or specific types. The core definition handles this via bracket notation:
# Type signature for configuration settings
class Configuration
def settings: () -> Hash[Symbol, String | Integer]
end
Union types, represented by the pipe operator (|), allow core classes to accept multiple valid types without resorting to untyped placeholders. If a method accepts either an Integer or a Float, you express it as Integer | Float. This accuracy prevents type checkers from throwing false positives during arithmetic operations.
Integrating Core Signatures with Type Checkers
Writing RBS files is only half the battle; checking them requires an engine. Static type checkers like Steep parse these core definitions alongside your implementation. They build an environment that maps every method call back to its core class definition.
If you invoke a method that doesn't exist on NilClass or String, the type checker flags it immediately. Consider a scenario where an optional lookup returns nil:
user = find_user(10)
puts user.name
If find_user returns User | nil, the type checker knows user might be nil. It will reject user.name unless you explicitly handle the nil case:
user = find_user(10)
if user
puts user.name
end
This guards against the classic undefined method error for nil without changing how runtime execution behaves.
Common Pitfalls When Typing Core Classes
Transitioning to type-safe Ruby often trips developers up in specific areas. Avoiding these mistakes ensures your signatures remain clean and maintainable.
- Overusing untyped: Falling back to
untypeddefeats the purpose of RBS. Always map variables to concrete core classes or custom types whenever possible. - Ignoring method overloading: Ruby methods often accept varying arguments. RBS supports method overloading by allowing multiple type definitions for a single method name.
- Mismatched arity in generics: Passing the wrong number of type parameters to generic core classes like
HashorArraywill trigger validation errors during environment building. - Neglecting mutable vs immutable states: Be precise with
attr_readerversusattr_accessorin your RBS files so the type checker knows which attributes permit reassignment.
Frequently Asked Questions
Does RBS slow down Ruby runtime performance?
No. RBS files are strictly static annotation files parsed by external development tools and type checkers. They are ignored by the Ruby interpreter at runtime, meaning zero impact on production execution speed.
How does RBS differ from Sorbet?
Sorbet relies on inline type annotations embedded directly inside Ruby source files. RBS uses separate .rbs files, keeping your clean Ruby code untouched and allowing multiple type checkers to parse the same definitions.
Can I generate RBS files automatically?
Yes. You can use tools like rbs prototype or typeprof to analyze existing Ruby source code and generate baseline RBS signature files, which you can then refine manually.
How do I handle duck typing in RBS?
RBS handles duck typing using interface definitions. You can define an interface with specific required methods, and any class that implements those methods will satisfy the type signature.