Back to Scaling Ruby Applications guides

Getting Started with RBS

Stanley Ulili
Updated on October 30, 2025

RBS is Ruby's official type signature language that ships with Ruby 3.0 and later. Unlike runtime type checkers that execute during program execution, RBS provides a separate syntax for describing the types of your Ruby code, letting you document interfaces and catch type mismatches through static analysis tools.

What sets RBS apart is its position as the standard solution maintained by the Ruby core team. While other type systems exist in the Ruby ecosystem, RBS represents the language's official direction for static typing. The signatures live in separate .rbs files rather than mixed into your Ruby code, which means your actual implementation stays clean and focused while type information exists alongside it.

This guide walks you through adding RBS to a Ruby project, writing your first type signatures, and using tools that leverage RBS to improve code quality.

Prerequisites

You'll need Ruby 3.0 or higher since RBS ships as part of the standard library starting from that version. This guide expects familiarity with Ruby's core concepts like classes, modules, and method definitions. Having Bundler installed will help manage the additional tooling we'll use for type checking.

Setting up your first RBS project

Let's build a new Ruby project to explore how RBS works in practice. Begin by creating a directory and setting up the basic structure:

 
mkdir rbs-demo && cd rbs-demo
 
bundle init

This generates a Gemfile in your project root. Open it and add the development tools for working with RBS:

Gemfile
source 'https://rubygems.org'

group :development do
gem 'rbs'
gem 'steep', require: false
end

The rbs gem provides command-line tools for validating and working with type signatures. Steep is a type checker that reads RBS files and verifies your Ruby code matches the declared types. Install everything with:

 
bundle install

Create a directory structure for your Ruby source and RBS signatures:

 
mkdir -p lib sig

The lib directory holds your actual Ruby code, while sig contains the corresponding RBS signature files. This separation keeps type information distinct from implementation.

Write a simple class to get started:

lib/calculator.rb
class Calculator
  def add(a, b)
    a + b
  end

  def multiply(a, b)
    a * b
  end
end

Initialize Steep in your project:

 
bundle exec steep init
Output
Writing Steepfile...

This creates a Steepfile with default configuration. Open it and uncomment the target configuration so Steep knows what to check:

Steepfile
target :lib do
  signature "sig"

  check "lib"
end

The configuration defines a target named :lib that looks for signatures in the sig directory and checks Ruby code in the lib directory. Run Steep to see the current state:

 
bundle exec steep check
Output

# Type checking files:

F

lib/calculator.rb:1:6: [warning] Cannot find the declaration of class: `Calculator`
│ Diagnostic ID: Ruby::UnknownConstant
│
â”” class Calculator
        ~~~~~~~~~~

Detected 1 problem from 1 file

Without any RBS signatures, Steep has no type information to verify. Now create the corresponding RBS file that declares what types your methods expect:

sig/calculator.rbs
class Calculator
  def add: (Integer, Integer) -> Integer
  def multiply: (Integer, Integer) -> Integer
end

The RBS syntax looks different from regular Ruby. Method signatures use a colon after the method name, followed by parameter types in parentheses, then an arrow pointing to the return type. Notice there are no parameter names in the signature - RBS cares about types, not names.

Run Steep again with the signatures in place:

 
bundle exec steep check
Output
# Type checking files:

..

No type error detected. 🧋

Steep confirms your implementation matches the declared types. Now let's intentionally introduce a type error to see how Steep catches it. Create a test file inside the lib directory that passes a string where an integer is expected:

lib/demo.rb
require_relative 'calculator'

calc = Calculator.new
result = calc.add("10", 5)
puts "Result: #{result}"

Run Steep to see the error:

 
bundle exec steep check
Output
# Type checking files:

..F

lib/demo.rb:4:18: [error] Cannot pass a value of type `::String` as an argument of type `::Integer`
│   ::String <: ::Integer
│     ::Object <: ::Integer
│       ::BasicObject <: ::Integer
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
â”” result = calc.add("10", 5)
                    ~~~~

Detected 1 problem from 1 file

Steep pinpoints exactly where the type mismatch occurs. The RBS signature declares add expects two Integer arguments, but we passed a String as the first argument. This is the value of type checking - catching these mismatches before your code runs.

Fix the error by using the correct type:

lib/demo.rb
require_relative 'calculator'

calc = Calculator.new
result = calc.add(10, 5)
puts "Result: #{result}" product = calc.multiply(3, 7) puts "Product: #{product}"

Run Steep once more:

 
bundle exec steep check
Output
# Type checking files:

...

No type error detected. 🫖

Now verify the code runs correctly:

 
ruby lib/demo.rb
Output
Result: 15
Product: 21

This demonstrates the core workflow with RBS and Steep: you write Ruby code, declare types in separate .rbs files, and let Steep verify they align. When mismatches occur, Steep tells you exactly where the problem lies. The signatures don't affect runtime behavior - they exist purely for documentation and static analysis.

Type checking with Steep

Having RBS signatures is useful for documentation, but their real power emerges when you run a type checker that verifies your implementation matches the declared types. We've already seen Steep catch a basic type mismatch, but let's explore how it handles more complex scenarios.

Steep performs static analysis by reading your RBS files and checking that every method call, variable assignment, and return value respects the declared types. Unlike runtime type checkers that execute during program execution, Steep catches errors without running your code.

Let's expand our calculator to demonstrate different kinds of type errors Steep can detect. Add a new method that has a more complex signature:

lib/calculator.rb
class Calculator
  def add(a, b)
    a + b
  end

  def multiply(a, b)
    a * b
  end

def divide(a, b)
return nil if b.zero?
a / b
end
end

The divide method returns nil when dividing by zero, otherwise it returns the result. Update the RBS signature to reflect this:

sig/calculator.rbs
class Calculator
  def add: (Integer, Integer) -> Integer
  def multiply: (Integer, Integer) -> Integer
def divide: (Integer, Integer) -> Integer?
end

The Integer? syntax is shorthand for Integer | NilClass, indicating the method might return an Integer or nil. Run Steep to confirm this signature is valid:

 
bundle exec steep check
Output
# Type checking files:

...

No type error detected. 🫖

Now create code that uses the result without checking for nil:

lib/demo.rb
require_relative 'calculator'

calc = Calculator.new

result = calc.divide(10, 2)
doubled = result * 2
puts "Doubled: #{doubled}"

Run Steep:

 
bundle exec steep check
Output
 Type checking files:

..F

lib/demo.rb:5:17: [error] Type `(::Integer | nil)` does not have method `*`
│ Diagnostic ID: Ruby::NoMethod
│
â”” doubled = result * 2
                   ~

Detected 1 problem from 1 file
stanley@MacBookPro rbs-demo % 

Steep catches that result might be nil, and you can't call * on nil. This is flow-sensitive typing in action - Steep tracks that divide returns a nullable type and prevents unsafe operations.

Fix this by checking for nil explicitly:

lib/demo.rb
require_relative 'calculator'

calc = Calculator.new

result = calc.divide(10, 2)
if result
doubled = result * 2
puts "Doubled: #{doubled}"
else
puts "Cannot divide by zero"
end

Run Steep again:

 
bundle exec steep check
Output
# Type checking files:

...

No type error detected. 🫖

After the if result check, Steep knows that inside the branch, result cannot be nil, so it's safe to perform arithmetic operations. This is called type narrowing - Steep automatically refines the type based on control flow.

The separation between Ruby code and RBS signatures means you can add type checking to existing projects without modifying the implementation. You write signatures separately, gradually covering more of your codebase, and use Steep to verify correctness as you go.

Understanding RBS signature syntax

RBS has its own syntax for declaring types that differs from Ruby code. Understanding how to write signatures for common Ruby patterns helps you document your interfaces effectively and get the most out of static type checking.

Method signatures follow a consistent pattern - the method name, a colon, parameter types in parentheses, an arrow, and the return type:

 
def add: (Integer, Integer) -> Integer
def greet: (String) -> String

Notice how parameter names are absent. RBS signatures focus purely on types, not identifiers. You can include parameter names for clarity, but they serve no semantic purpose:

 
def add: (Integer a, Integer b) -> Integer

Both forms are valid, though the version without names is more common since RBS cares only about the type structure.

Methods that return nothing use void as the return type:

 
def reset: () -> void
def log_message: (String) -> void

The empty parentheses indicate no parameters, while void signals the method performs an action but doesn't return a meaningful value.

Instance variables need explicit type declarations at the class level:

 
class User
  @name: String
  @age: Integer
  @email: String?

  def initialize: (String, Integer, ?String) -> void
end

The @name, @age, and @email declarations tell RBS what types these instance variables hold. This helps Steep understand what happens inside methods that access these variables. Notice @email uses String? to indicate it might be nil.

Attribute readers and writers also need signatures:

 
class User
  @name: String

  attr_reader name: String
  attr_writer name: String
  attr_accessor name: String
end

These generate the appropriate getter and setter methods with their corresponding type signatures.

Generic types like arrays and hashes use square bracket syntax:

 
def process_numbers: (Array[Integer]) -> Integer
def build_mapping: (Hash[String, Integer]) -> void
def fetch_users: () -> Array[User]

Array[Integer] means an array containing integers, while Hash[String, Integer] represents a hash with string keys and integer values. You specify what the container holds inside the brackets.

For hashes with specific required keys, use the hash literal syntax:

 
def user_data: () -> { name: String, age: Integer, email: String? }
def config: () -> { timeout: Integer, retries: Integer }

This differs from Hash[Symbol, String] which would allow any symbol keys. The literal syntax enforces exact keys with specific types for each.

Union types use the pipe character to represent "or":

 
def process: (Integer | String) -> String
def find_user: (Integer) -> User?
def parse: (String | Integer | Float) -> Numeric

The Integer | String means the method accepts either type. The shorthand User? expands to User | NilClass, which you'll use frequently for nullable values.

For methods that accept blocks, use curly braces to describe the block's signature:

 
def each_double: (Array[Integer]) { (Integer) -> void } -> void
def transform: (Array[String]) { (String) -> Integer } -> Array[Integer]

The { (Integer) -> void } describes a block that receives an Integer parameter and returns nothing. The block signature sits between the regular parameters and the method's return type.

Optional parameters appear with a question mark before the type:

 
def greet: (String, ?Integer) -> String
def configure: (?timeout: Integer, ?retries: Integer) -> void

The ?Integer indicates the parameter is optional with a default value. For keyword arguments, the syntax places the question mark before the keyword name.

Class methods use self. prefix to distinguish them from instance methods:

 
class MathUtils
  def self.square: (Integer) -> Integer
  def self.cube: (Integer) -> Integer
end

These signatures describe methods called on the class itself rather than instances.

When methods can be called multiple ways, you can overload signatures:

 
def fetch: (Integer) -> User
       | (String) -> User
       | (Integer, String) -> User

Each line represents a different valid way to call the method. The pipe character at the start of continuation lines indicates this is one method with multiple signatures, not multiple methods.

Generic type parameters let you write signatures for classes that work with any type:

 
class Box[T]
  @value: T

  def initialize: (T) -> void
  def get: () -> T
  def set: (T) -> void
end

The [T] declares a type parameter that gets filled in when you use the class. This lets you describe containers and wrappers that preserve type information about their contents.

RBS also supports describing more advanced Ruby features like modules, mix-ins, and inheritance:

 
module Enumerable[Elem, Return]
  def map: [U] () { (Elem) -> U } -> Array[U]
  def select: () { (Elem) -> bool } -> Return
end

class User
  include Comparable
  extend Forwardable
end

The syntax maps closely to Ruby's structure, making it natural to express your code's contracts. Once you internalize these patterns, writing RBS signatures becomes straightforward - you're simply declaring what your Ruby code already does, just in a more formal notation that tools can verify.

Generating signatures with TypeProf

Writing RBS signatures by hand for an entire codebase takes time. TypeProf helps by analyzing your Ruby code and generating signatures automatically. While the generated signatures aren't always perfect, they provide a solid starting point that you can refine.

TypeProf performs type inference by analyzing how values flow through your code, similar to how type systems work in languages like TypeScript or Flow. First, add it to your Gemfile:

Gemfile
source 'https://rubygems.org'

group :development do
  gem 'rbs'
  gem 'steep', require: false
gem 'typeprof', require: false
end

Install the gem:

 
bundle install

Now let's create a new class without writing the signature first:

lib/user.rb
class User
  def initialize(name, email, age)
    @name = name
    @email = email
    @age = age
  end

  def summary
    "#{@name} (#{@age})"
  end

  def contact_info
    { email: @email, name: @name }
  end

  def adult?
    @age >= 18
  end
end

Run TypeProf on this file:

 
bundle exec typeprof lib/user.rb
Output
# TypeProf 0.21.11

# Classes
class User
  @name: String
  @email: String
  @age: Integer

  def initialize: (String, String, Integer) -> void
  def summary: -> String
  def contact_info: -> {email: String, name: String}
  def adult?: -> bool
end

TypeProf analyzed the code and inferred types based on how values flow through the methods. It correctly identified that @name and @email are strings, @age is an integer, and adult? returns a boolean.

Save this output to a file:

 
bundle exec typeprof lib/user.rb > sig/user.rbs

Run Steep to verify the generated signature works:

 
bundle exec steep check
Output
# Type checking files:

....

No type error detected. 🫖

The automatically generated signature passes type checking. You now have type coverage for this class without writing the signature manually.

TypeProf's inference works best when your code includes clear type information through literals and method calls. Create a file that uses the User class with concrete values:

lib/user_demo.rb
require_relative 'user'

user = User.new("Alice", "alice@example.com", 25)
puts user.summary
puts user.adult?

info = user.contact_info
puts "Email: #{info[:email]}"

Run TypeProf on both files together:

 
bundle exec typeprof lib/user.rb lib/user_demo.rb
Output
# TypeProf 0.21.11

# Classes
class User
  @name: String
  @email: String
  @age: Integer

  def initialize: (String, String, Integer) -> void
  def summary: -> String
  def contact_info: -> {email: String, name: String}
  def adult?: -> bool
end

TypeProf produces the same signatures because the usage in user_demo.rb confirms its inferences. When you provide example usage, TypeProf can be more confident about the types.

TypeProf has limitations though. It struggles with dynamic code where types aren't clear from static analysis. Here's an example:

lib/flexible.rb
class FlexibleProcessor
  def process(input)
    if input.respond_to?(:upcase)
      input.upcase
    else
      input.to_s
    end
  end
end

Run TypeProf on it:

 
bundle exec typeprof lib/flexible.rb
Output
# TypeProf 0.21.11

# Classes
class FlexibleProcessor
  def process: (untyped) -> String
end

TypeProf sees that input could be various types and falls back to untyped. The untyped type means "any type at all" - TypeProf couldn't infer something more specific.

You might think writing a more specific signature by hand would be better:

sig/flexible.rbs
class FlexibleProcessor
  def process: (String | Integer) -> String
end

But run Steep and you'll see a problem:

 
bundle exec steep check
Output
# Type checking files:

.......F.

lib/flexible.rb:4:12: [error] Type `(::String | ::Integer)` does not have method `upcase`
│ Diagnostic ID: Ruby::NoMethod
│
â””       input.upcase
              ~~~~~~

Detected 1 problem from 1 file

Steep correctly identifies that you can't call upcase on an Integer. The implementation checks respond_to?(:upcase) at runtime, but Steep can't verify that check will work correctly. This reveals an important point: sometimes untyped is the honest answer when your code is too dynamic for static analysis.

For genuinely dynamic code like this, you have three options. Keep the untyped signature and accept less type safety:

sig/flexible.rbs
class FlexibleProcessor
  def process: (untyped) -> String
end

Or refactor the implementation to use proper type narrowing:

lib/flexible.rb
class FlexibleProcessor
  def process(input)
    if input.is_a?(String)
      input.upcase
    else
      input.to_s
    end
  end
end

Now Steep can understand the type narrowing through is_a?:

 
bundle exec steep check
Output
# Type checking files:

.........

No type error detected. 🫖

Or split it into separate methods with clear signatures:

lib/flexible.rb
class FlexibleProcessor
  def process_string(input)
    input.upcase
  end

  def process_integer(input)
    input.to_s
  end
end
sig/flexible.rbs
class FlexibleProcessor
  def process_string: (String) -> String
  def process_integer: (Integer) -> String
end

This gives you the strongest type safety because each method has a single, clear purpose.

Update the generated sig/user.rbs file to be more precise. Open it and refine the types:

sig/user.rbs
class User
@name: String
@email: String
@age: Integer
attr_reader name: String
attr_reader email: String
attr_reader age: Integer
def initialize: (String name, String email, Integer age) -> void def summary: () -> String def contact_info: () -> { email: String, name: String } def adult?: () -> bool end

We added attr_reader declarations for the instance variables and included parameter names for clarity. Run Steep to confirm everything still type-checks:

 
bundle exec steep check
Output
# Type checking files:

.........

No type error detected. 🫖

The workflow with TypeProf is: generate initial signatures automatically, review them for accuracy, and refine where TypeProf was too conservative or imprecise. Sometimes TypeProf's untyped is actually the right answer for dynamic code, and sometimes it reveals opportunities to refactor toward more statically analyzable patterns.

Use TypeProf as a productivity tool to bootstrap your RBS files quickly, then invest time in making them more precise where it matters most for your codebase.

Final thoughts

This article walked you through RBS, Ruby's official type signature system that brings static type checking without changing how your code runs. The signatures live in separate .rbs files, letting you add type safety gradually to existing projects.

By writing signatures and using Steep to verify them, you catch type mismatches before runtime. TypeProf speeds this up by generating initial signatures automatically, which you then refine based on your code's actual behavior.

RBS ships with Ruby 3.0+ and represents the language's official direction for static typing. Adopting it incrementally gives you stronger guarantees about method contracts while keeping the flexibility that makes Ruby productive.