# Getting Started with RBS

[RBS](https://github.com/ruby/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.

[ad-logs]

## 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:

```command
mkdir rbs-demo && cd rbs-demo
```

```command
bundle init
```

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

```ruby
[label Gemfile]
source 'https://rubygems.org'

[highlight]
group :development do
  gem 'rbs'
  gem 'steep', require: false
end
[/highlight]
```

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:

```command
bundle install
```

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

```command
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:

```ruby
[label 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:

```command
bundle exec steep init
```

```text
[output]
Writing Steepfile...
```

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

```ruby
[label 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:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label 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:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label lib/demo.rb]
require_relative 'calculator'

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

Run Steep to see the error:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label lib/demo.rb]
require_relative 'calculator'

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

product = calc.multiply(3, 7)
puts "Product: #{product}"
```

Run Steep once more:

```command
bundle exec steep check
```

```text
[output]
# Type checking files:

...

No type error detected. 🫖
```

Now verify the code runs correctly:

```command
ruby lib/demo.rb
```

```text
[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:

```ruby
[label lib/calculator.rb]
class Calculator
  def add(a, b)
    a + b
  end

  def multiply(a, b)
    a * b
  end

  [highlight]
  def divide(a, b)
    return nil if b.zero?
    a / b
  end
  [/highlight]
end
```

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

```ruby
[label sig/calculator.rbs]
class Calculator
  def add: (Integer, Integer) -> Integer
  def multiply: (Integer, Integer) -> Integer
  [highlight]
  def divide: (Integer, Integer) -> Integer?
  [/highlight]
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:

```command
bundle exec steep check
```

```text
[output]
# Type checking files:

...

No type error detected. 🫖
```

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

```ruby
[label lib/demo.rb]
require_relative 'calculator'

calc = Calculator.new

[highlight]
result = calc.divide(10, 2)
doubled = result * 2
puts "Doubled: #{doubled}"
[/highlight]
```

Run Steep:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label lib/demo.rb]
require_relative 'calculator'

calc = Calculator.new

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

Run Steep again:

```command
bundle exec steep check
```

```text
[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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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":

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
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:

```ruby
[label Gemfile]
source 'https://rubygems.org'

group :development do
  gem 'rbs'
  gem 'steep', require: false
  [highlight]
  gem 'typeprof', require: false
  [/highlight]
end
```

Install the gem:

```command
bundle install
```

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

```ruby
[label 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:

```command
bundle exec typeprof lib/user.rb
```

```text
[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:

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

Run Steep to verify the generated signature works:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label 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:

```command
bundle exec typeprof lib/user.rb lib/user_demo.rb
```

```text
[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:

```ruby
[label 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:

```command
bundle exec typeprof lib/flexible.rb
```

```text
[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:

```ruby
[label sig/flexible.rbs]
class FlexibleProcessor
  def process: (String | Integer) -> String
end
```

But run Steep and you'll see a problem:

```command
bundle exec steep check
```

```text
[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:

```ruby
[label sig/flexible.rbs]
class FlexibleProcessor
  def process: (untyped) -> String
end
```

Or refactor the implementation to use proper type narrowing:

```ruby
[label 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?`:

```command
bundle exec steep check
```

```text
[output]
# Type checking files:

.........

No type error detected. 🫖
```

Or split it into separate methods with clear signatures:

```ruby
[label lib/flexible.rb]
class FlexibleProcessor
  def process_string(input)
    input.upcase
  end

  def process_integer(input)
    input.to_s
  end
end
```

```ruby
[label 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:

```ruby
[label sig/user.rbs]
class User
  [highlight]
  @name: String
  @email: String
  @age: Integer

  attr_reader name: String
  attr_reader email: String
  attr_reader age: Integer
  [/highlight]

  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:

```command
bundle exec steep check
```

```text
[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.