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:
This generates a Gemfile in your project root. Open it and add the development tools for working with RBS:
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:
Create a directory structure for your Ruby source and RBS signatures:
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:
Initialize Steep in your project:
This creates a Steepfile with default configuration. Open it and uncomment the target configuration so Steep knows what to check:
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:
Without any RBS signatures, Steep has no type information to verify. Now create the corresponding RBS file that declares what types your methods expect:
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:
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:
Run Steep to see the error:
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:
Run Steep once more:
Now verify the code runs correctly:
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:
The divide method returns nil when dividing by zero, otherwise it returns the result. Update the RBS signature to reflect this:
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:
Now create code that uses the result without checking for nil:
Run Steep:
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:
Run Steep again:
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:
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:
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:
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:
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:
These generate the appropriate getter and setter methods with their corresponding type signatures.
Generic types like arrays and hashes use square bracket syntax:
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:
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":
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:
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:
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:
These signatures describe methods called on the class itself rather than instances.
When methods can be called multiple ways, you can overload signatures:
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:
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:
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:
Install the gem:
Now let's create a new class without writing the signature first:
Run TypeProf on this file:
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:
Run Steep to verify the generated signature works:
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:
Run TypeProf on both files together:
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:
Run TypeProf on it:
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:
But run Steep and you'll see a problem:
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:
Or refactor the implementation to use proper type narrowing:
Now Steep can understand the type narrowing through is_a?:
Or split it into separate methods with clear signatures:
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:
We added attr_reader declarations for the instance variables and included parameter names for clarity. Run Steep to confirm everything still type-checks:
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.