Introduction to Ruby's Type System
Ruby is renowned for its elegant syntax and developer happiness, but one of its most defining characteristics is its approach to typing. Understanding Ruby's type system—particularly the distinction between static and dynamic typing—is crucial for writing robust, maintainable code. This tutorial will explore both paradigms, how they apply to Ruby, and how modern tools like RBS and Sorbet are bridging the gap.
What Is a Type System?
A type system is a set of rules that assigns a property called a "type" to the various constructs of a computer program, such as variables, expressions, functions, and modules. The main purposes of a type system are:
- Reducing bugs by preventing type-related errors
- Enabling better documentation and code readability
- Providing optimization opportunities for compilers
- Enforcing abstractions and design contracts
The two primary categories of type systems are static typing, where types are checked at compile time, and dynamic typing, where types are checked at runtime. Ruby has traditionally been dynamically typed, but recent developments have introduced static typing capabilities.
Dynamic Typing in Ruby
Ruby is dynamically typed, meaning that type checking occurs at runtime rather than compile time. Variables do not have inherent types; instead, the values they reference carry the type information. This allows for tremendous flexibility and rapid development.
How Dynamic Typing Works in Ruby
In Ruby, a variable can hold any type of object, and the same variable can be reassigned to a different type at any point. The interpreter determines whether an operation is valid only when the code executes.
# Dynamic typing in action
x = 42 # x is an Integer
puts x.class # => Integer
x = "hello" # x is now a String
puts x.class # => String
x = [1, 2, 3] # x is now an Array
puts x.class # => Array
This flexibility extends to method parameters as well. Ruby methods do not declare parameter types, so any object can be passed to any method. The method will succeed as long as the object responds to the messages sent to it.
# Duck typing example
def describe(item)
puts "This item has #{item.size} elements"
end
describe("hello") # => This item has 5 elements
describe([1, 2, 3]) # => This item has 3 elements
describe({a: 1, b: 2}) # => This item has 2 elements
Duck Typing: Ruby's Core Philosophy
Ruby embraces "duck typing," a concept derived from the saying: "If it walks like a duck and quacks like a duck, then it must be a duck." In programming terms, the actual type of an object matters less than the methods it defines. This is a powerful form of polymorphism.
# Duck typing in practice
class AudioFile
def play
puts "Playing audio file..."
end
end
class VideoFile
def play
puts "Playing video file..."
end
end
class MediaPlayer
def play_media(media)
# We don't check the type, we just call play
media.play
end
end
player = MediaPlayer.new
player.play_media(AudioFile.new) # => Playing audio file...
player.play_media(VideoFile.new) # => Playing video file...
Advantages of Dynamic Typing
- Rapid prototyping: Developers can write and iterate on code quickly without type declarations.
- Flexibility: The same code can work with different types, reducing boilerplate.
- Metaprogramming: Dynamic typing enables powerful metaprogramming capabilities that would be difficult or impossible in statically typed languages.
- Concise code: Less ceremony means more focus on business logic.
Disadvantages of Dynamic Typing
- Runtime errors: Type-related bugs are discovered only when the code executes, potentially in production.
- Reduced tooling support: IDEs have limited ability to provide autocompletion and refactoring assistance.
- Documentation challenges: Without type annotations, understanding what a method expects and returns requires reading implementation details.
- Refactoring risks: Changing method signatures can silently break code in distant parts of an application.
Static Typing in Ruby
While Ruby is inherently dynamically typed, the Ruby community has recognized the value of static type checking, especially in large codebases. Two major solutions have emerged: RBS, the official type signature language introduced in Ruby 3.0, and Sorbet, a type checker developed by Stripe.
RBS: Ruby's Official Type Signature Language
RBS is a language for describing the types of Ruby programs. It allows developers to write type definitions in separate .rbs files, keeping the Ruby code itself clean and unchanged. The typeprof tool can even generate RBS files automatically by analyzing existing code.
# user.rb
class User
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def greet
"Hello, I'm #{@name}!"
end
def adult?
@age >= 18
end
end
# user.rbs
class User
attr_reader name: String
attr_reader age: Integer
def initialize: (String name, Integer age) -> void
def greet: -> String
def adult?: -> bool
end
Once you have RBS files, you can use the rbs gem to validate your code against these type definitions:
# Install the rbs gem
# gem install rbs
# Validate your type signatures
# rbs validate
# Check a Ruby file against signatures
# rbs -r user.rb
Type Checking with TypeProf
TypeProf is a type inference tool bundled with Ruby 3.0+. It can analyze Ruby code and generate RBS type signatures automatically, making it easier to adopt static typing in existing projects.
# Running TypeProf on a Ruby file
# typeprof user.rb
# Output might look like:
# class User
# attr_reader name: String
# attr_reader age: Integer
# def initialize: (String, Integer) -> void
# def greet: -> String
# def adult?: -> bool
# end
Sorbet: A Powerful Type Checker for Ruby
Sorbet, developed and used at scale by Stripe, is a gradual type checker for Ruby. Unlike RBS, Sorbet uses inline type annotations in Ruby files through the sorbet-runtime gem and provides fast, incremental type checking.
# typed: true
# Install Sorbet:
# gem install sorbet
require 'sorbet-runtime'
class User
extend T::Sig
sig { params(name: String, age: Integer).void }
def initialize(name, age)
@name = name
@age = age
end
sig { returns(String) }
attr_reader :name
sig { returns(Integer) }
attr_reader :age
sig { returns(String) }
def greet
"Hello, I'm #{@name}!"
end
sig { returns(T::Boolean) }
def adult?
@age >= 18
end
end
Sorbet provides real-time type checking in editors and can catch type errors before code is even run:
# typed: true
require 'sorbet-runtime'
class Calculator
extend T::Sig
sig { params(a: Integer, b: Integer).returns(Integer) }
def add(a, b)
a + b
end
end
calc = Calculator.new
result = calc.add(5, 10) # OK
result = calc.add(5, "10") # Sorbet will flag this as a type error
Gradual Typing with Sorbet
One of Sorbet's strengths is its support for gradual typing. You can specify different strictness levels on a per-file basis, allowing teams to adopt static typing incrementally.
# typed: false - No type checking
# typed: true - Basic type checking
# typed: strict - All methods must have signatures
# typed: strong - No untyped code allowed at all
# Example: typed: strict
# typed: strict
require 'sorbet-runtime'
class BankAccount
extend T::Sig
sig { params(initial_balance: Integer).void }
def initialize(initial_balance)
@balance = T.let(initial_balance, Integer)
end
sig { params(amount: Integer).returns(Integer) }
def deposit(amount)
@balance += amount
end
sig { returns(Integer) }
attr_reader :balance
end
Static vs Dynamic Typing: A Comparison
Understanding when to use static versus dynamic typing depends on your project's needs, team size, and codebase complexity. Let's compare the two approaches in the context of Ruby development.
Development Speed
Dynamic typing generally allows for faster initial development. You can write code without worrying about type annotations, which is particularly beneficial for small projects, scripts, and prototypes.
# Dynamic typing - quick and concise
def process_data(data)
data.map { |item| item.transform_keys(&:to_s) }
end
# With Sorbet - more verbose but safer
sig { params(data: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Array[T::Hash[String, T.untyped]]) }
def process_data(data)
data.map { |item| item.transform_keys(&:to_s) }
end
Error Detection
Static typing catches errors before runtime, which is invaluable for large codebases. Consider this example where a type error would only surface at runtime in dynamic typing:
# Dynamic typing - error only at runtime
def calculate_total(prices)
prices.sum
end
# This works
calculate_total([10, 20, 30]) # => 60
# This raises NoMethodError at runtime
calculate_total("10,20,30") # => NoMethodError: undefined method `sum'
# With Sorbet - error caught at check time
sig { params(prices: T::Array[Integer]).returns(Integer) }
def calculate_total(prices)
prices.sum
end
# Sorbet catches this before running
calculate_total("10,20,30") # => Type error detected by Sorbet
Refactoring Confidence
Static typing provides a safety net when refactoring. If you change a method signature, the type checker immediately identifies all the places that need updating.
# Before refactoring
sig { params(user: User).returns(String) }
def format_user(user)
"#{user.name} (#{user.age})"
end
# After refactoring - age is now a Date
sig { params(user: User).returns(String) }
def format_user(user)
"#{user.name} (#{user.age})" # Sorbet flags: age is now Date, not Integer
end
Documentation and Readability
Type signatures serve as executable documentation. They clearly communicate what a method expects and what it returns, making code easier to understand for new team members.
# Without types - unclear what this method expects
def create_order(customer, items, options = {})
# ... implementation
end
# With RBS - clear contract
# def create_order: (
# Customer customer,
# Array[Item] items,
# ?{ (Order) -> void } block
# ) -> Order
Best Practices for Ruby Typing
Whether you choose dynamic typing, static typing, or a gradual approach, following best practices will help you write better Ruby code.
1. Embrace Duck Typing Thoughtfully
When using dynamic typing, design your code around behaviors rather than types. This leads to more flexible and reusable code.
# Good: Duck typing with clear behavior expectations
class ReportGenerator
def generate(data_source)
data = data_source.fetch
formatted = format(data)
export(formatted)
end
private
def format(data)
# Format expects data to respond to :each
data.map { |row| row.values.join(",") }
end
def export(content)
# Any object responding to :write works
File.write("report.csv", content)
end
end
# Works with any data source that responds to :fetch
class DatabaseSource
def fetch
# ... returns array of rows
end
end
class ApiSource
def fetch
# ... returns array of rows
end
end
2. Use Meaningful Variable and Method Names
In dynamically typed code, clear naming becomes even more important since types aren't explicitly declared.
# Poor naming - unclear types
def process(d, o)
d.each { |i| o.write(i) }
end
# Good naming - types are implied
def write_records(records, output_stream)
records.each { |record| output_stream.write(record) }
end
3. Add Runtime Checks for Critical Paths
Even in dynamically typed code, adding runtime validations for critical operations can prevent subtle bugs.
def transfer_funds(from_account, to_account, amount)
raise ArgumentError, "Amount must be positive" unless amount.is_a?(Numeric) && amount > 0
raise ArgumentError, "Insufficient funds" if from_account.balance < amount
from_account.withdraw(amount)
to_account.deposit(amount)
end
4. Adopt Static Typing Gradually
If you decide to introduce static typing, start with the most critical or error-prone parts of your codebase. Both RBS and Sorbet support gradual adoption.
# Step 1: Start with typed: false
# typed: false
# Step 2: Move to typed: true and add signatures to key methods
# typed: true
require 'sorbet-runtime'
class PaymentProcessor
extend T::Sig
sig { params(amount: Integer, currency: String).returns(T::Hash[Symbol, T.untyped]) }
def process_payment(amount, currency)
# Core business logic with type safety
{ status: "success", amount: amount, currency: currency }
end
# Untyped methods are still allowed at this level
def log_transaction(transaction)
puts "Transaction: #{transaction.inspect}"
end
end
# Step 3: Eventually move to typed: strict for full coverage
# typed: strict
5. Keep Type Definitions in Sync
When using RBS, ensure your type signatures stay synchronized with your implementation. Consider integrating type checking into your CI/CD pipeline.
# In your CI pipeline (e.g., .github/workflows/ci.yml or .gitlab-ci.yml)
# jobs:
# type_check:
# steps:
# - run: bundle exec rbs validate
# - run: bundle exec srb tc # If using Sorbet
6. Use Union Types and Generics Appropriately
Both RBS and Sorbet support advanced type features. Use them to accurately model your domain.
# RBS: Union types and generics
class Repository
def find: (Integer id) -> (User | nil)
def all: () -> Array[User]
def search: (String query) -> Array[User]
end
# Sorbet: Union types and generics
class Repository
extend T::Sig
sig { params(id: Integer).returns(T.nilable(User)) }
def find(id)
# Returns User or nil
end
sig { returns(T::Array[User]) }
def all
# Returns array of Users
end
sig { params(query: String).returns(T::Array[User]) }
def search(query)
# Returns array of Users matching query
end
end
7. Leverage Type Inference Where Possible
Don't over-annotate. Both Sorbet and RBS with TypeProf can infer many types automatically. Focus your annotation effort on public APIs and complex logic.
# Sorbet can infer local variable types
sig { params(users: T::Array[User]).returns(Integer) }
def count_adults(users)
adults = users.select(&:adult?) # Sorbet infers: T::Array[User]
adults.count # Sorbet infers: Integer
end
Real-World Example: Building a Type-Safe Service
Let's build a practical example that demonstrates both dynamic and static approaches to the same service.
Dynamic Version
# order_service.rb - Dynamic typing
class OrderService
def initialize(repository, notifier)
@repository = repository
@notifier = notifier
end
def create_order(customer_id, items)
customer = @repository.find_customer(customer_id)
raise "Customer not found" unless customer
order = Order.new(customer, items)
order.calculate_total
@repository.save_order(order)
@notifier.send_confirmation(order)
order
end
def cancel_order(order_id)
order = @repository.find_order(order_id)
raise "Order not found" unless order
order.cancel
@repository.save_order(order)
@notifier.send_cancellation(order)
order
end
end
Static Version with Sorbet
# typed: strict
require 'sorbet-runtime'
class OrderService
extend T::Sig
sig do
params(
repository: OrderRepository,
notifier: NotificationService
).void
end
def initialize(repository, notifier)
@repository = repository
@notifier = notifier
end
sig do
params(
customer_id: Integer,
items: T::Array[OrderItem]
).returns(Order)
end
def create_order(customer_id, items)
customer = @repository.find_customer(customer_id)
raise "Customer not found" if customer.nil?
order = Order.new(customer, items)
order.calculate_total
@repository.save_order(order)
@notifier.send_confirmation(order)
order
end
sig { params(order_id: Integer).returns(Order) }
def cancel_order(order_id)
order = @repository.find_order(order_id)
raise "Order not found" if order.nil?
order.cancel
@repository.save_order(order)
@notifier.send_cancellation(order)
order
end
private
sig { returns(OrderRepository) }
attr_reader :repository
sig { returns(NotificationService) }
attr_reader :notifier
end
Static Version with RBS
# order_service.rb - Implementation stays clean
class OrderService
def initialize(repository, notifier)
@repository = repository
@notifier = notifier
end
def create_order(customer_id, items)
customer = @repository.find_customer(customer_id)
raise "Customer not found" unless customer
order = Order.new(customer, items)
order.calculate_total
@repository.save_order(order)
@notifier.send_confirmation(order)
order
end
def cancel_order(order_id)
order = @repository.find_order(order_id)
raise "Order not found" unless order
order.cancel
@repository.save_order(order)
@notifier.send_cancellation(order)
order
end
end
# order_service.rbs - Type signatures in separate file
class OrderService
def initialize: (OrderRepository repository, NotificationService notifier) -> void
def create_order: (Integer customer_id, Array[OrderItem] items) -> Order
def cancel_order: (Integer order_id) -> Order
end
Choosing the Right Approach
The decision between static and dynamic typing in Ruby depends on several factors:
When to Stick with Dynamic Typing
- Small to medium-sized projects with a small team
- Rapid prototyping and proof-of-concept work
- Scripts and automation tools
- Projects heavily relying on metaprogramming
- When development speed is the top priority
When to Adopt Static Typing
- Large codebases with many developers
- Long-lived projects that require maintainability
- Financial or safety-critical applications
- Projects with high refactoring frequency
- When onboarding new developers who benefit from type documentation
- Open-source libraries where clear contracts help users
Hybrid Approach: The Best of Both Worlds
Many teams find success with a hybrid approach, using dynamic typing for exploration and prototyping, then adding static types as code stabilizes. Sorbet's gradual typing makes this particularly practical.
# Start dynamic for prototyping
# typed: false
class Experiment
def try_different_approaches(data)
# Explore freely without type constraints
result = data.transform_values { |v| v.to_s.upcase }
result.select { |_, v| v.length > 3 }
end
end
# Once stable, add types
# typed: true
class Experiment
extend T::Sig
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, String]) }
def try_different_approaches(data)
result = data.transform_values { |v| v.to_s.upcase }
result.select { |_, v| v.length > 3 }
end
end
Conclusion
Ruby's type system has evolved significantly, offering developers the flexibility of dynamic typing alongside the safety of static typing through tools like RBS and Sorbet. Dynamic typing remains Ruby's default and is excellent for rapid development, prototyping, and projects that benefit from maximum flexibility. However, as codebases grow and teams expand, the benefits of static typing—early error detection, better documentation, improved refactoring confidence, and enhanced tooling support—become increasingly valuable. The key is to understand the trade-offs and choose the approach that best fits your project's needs. Whether you embrace Ruby's dynamic nature fully, adopt static typing from the start, or gradually introduce type checking as your project matures, the modern Ruby ecosystem provides the tools you need to write clean, safe, and maintainable code. Remember that types are not an end in themselves but a means to building better software—use them thoughtfully to enhance, not hinder, your development experience.