← Back to DevBytes

Ruby Type System: Static vs Dynamic Typing

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:

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

Disadvantages of Dynamic Typing

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

When to Adopt Static Typing

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles