Type Checking with RBS, TypeProf, Steep and Sorbet

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby does not check types before a program runs and has no type annotations in its syntax. It is still possible to describe the types of a program, in a separate language called RBS, and to check code against those descriptions with tools. This page explains that language and the tools around it: RBS and TypeProf, which ship with Ruby as bundled gems, and Steep and Sorbet, which are community projects that you install yourself and that are not part of Ruby. The aim is to help you decide how much of this machinery a project needs, because the language itself never requires it.

It builds on Methods and Parameters (the parameter kinds that method types describe), Struct, Data and Value Objects (how value objects appear in signatures), RubyGems and Bundler (bundled gems and Gemfile.lock, which rbs collection reads) and Testing with Minitest, whose tests double as the workload for runtime signature checking below. The tools that look at style rather than types are on Style, Linting and Static Analysis.

The examples use the shelf gem (a small library catalogue, see the shelf gem source). The RBS files were validated with rbs validate, the Ruby files were syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the gem’s configuration, and the outputs shown are real. TypeProf 0.31.1 and RBS 3.10.0, the versions bundled with Ruby 4.0, produced the RBS and TypeProf output. Steep, Sorbet and rbs-inline are not installed on the machine used for this page, so their listings are described from their official documentation and were not run; each says so.

The map of the tools

flowchart LR CODE["Ruby code in lib/"] --> TP["TypeProf: infers types from the code"] CODE --> PROTO["rbs prototype: draft signatures"] INL["rbs-inline comments"] --> SIG TP --> SIG["RBS files in sig/"] PROTO --> SIG SIG --> VAL["rbs validate: are the signatures consistent?"] SIG --> STEEP["Steep: checks the code against the signatures"] SIG --> RT["rbs test: checks the signatures while tests run"] CODE --> SORBET["Sorbet: reads sig blocks inside the code"] SORBET --> RBI["RBI files in sorbet/rbi/"]

There are two families. RBS keeps types in files next to the code, so the Ruby stays plain and the types can be added to a gem that you do not control. Sorbet keeps types in the code, as sig blocks that run, and uses its own RBI files for the rest. Both are gradual: anything not described is untyped and is accepted.

Tool Role Ships with Ruby 4.0?

RBS

Signature language and the rbs command

Yes, bundled gem 3.10.0

TypeProf

Infers types from code, language-server mode

Yes, bundled gem 0.31.1 (its README calls it experimental)

rbs-inline

Writes RBS as comments in the Ruby file

No (community)

Steep

Static checker for RBS (community, Soutaro Matsumoto)

No

Sorbet

Static checker plus runtime sig (community, Stripe)

No

The bundled versions are listed in the release notes of each Ruby, and they were confirmed by listing the gems of each installed Ruby.

Ruby RBS TypeProf

3.1.7

2.7.0

0.21.3

3.2.11

2.8.2

0.21.3

3.3.12

3.4.0

0.21.9

3.4.11

3.8.0

0.30.1

4.0.7

3.10.0

0.31.1

Bundled gems are installed with Ruby but are not loaded by require under Bundler unless they are in the Gemfile. A project that runs rbs or typeprof in CI should list them there, with versions.

group :development do
  gem "rbs", "~> 3.10"
  gem "typeprof", "~> 0.31"
end

What RBS is

RBS is a language with its own parser, a file extension (.rbs) and a convention of keeping files in sig/. A signature file never runs. It declares classes, modules, methods, constants and types, and tools read it. It describes the shape of an API, not its implementation, so the same file can document a gem, drive a checker, and generate documentation or editor completion.

The listing below is the signature for the shelf gem. It shows classes, a module with an interface, an alias for a union of symbols, overloads, a generic mixin and a block type.

module Shelf
  VERSION: String

  class Error < StandardError
  end

  class Isbn
    attr_reader digits: String

    def self.new: (String digits) -> Isbn
                | (digits: String) -> Isbn
    def initialize: (digits: String) -> void
    def to_s: () -> String

    private

    def valid?: (String value) -> bool
  end

  class Author
    attr_reader name: String

    def self.new: (String name) -> Author
                | (name: String) -> Author
  end

  class Book
    attr_reader isbn: Isbn
    attr_reader title: String
    attr_reader author: Author

    def self.new: (Isbn isbn, String title, Author author) -> Book
                | (isbn: Isbn, title: String, author: Author) -> Book
    def to_s: () -> String
  end

  type loan_state = :available | :lent | :overdue | :returned

  class Loan
    STATES: Array[loan_state]

    attr_reader book: Book
    attr_reader state: loan_state

    def initialize: (Book book, ?state: loan_state) -> void
    def describe: () -> String
  end

  module Store
    interface _Store
      def all: () -> Array[Book]
      def add: (Book book) -> self
    end

    class Memory
      include _Store

      def initialize: () -> void
    end
  end

  class Catalog
    include Enumerable[Book]

    def initialize: (?store: Store::_Store) -> void
    def add: (Book book) -> self
    def each: () { (Book) -> void } -> void
            | () -> Enumerator[Book, void]
  end

  class Config < Struct[untyped]
    attr_accessor store: Store::_Store
    attr_accessor loan_days: Integer
  end

  self.@config: Config?

  def self.config: () -> Config
  def self.configure: () { (Config) -> void } -> Config
end

Points worth reading closely in this file:

  • Isbn, Author and Book are created with Data.define, which defines new with both positional and keyword forms, so new has two overloads separated by |. rbs prototype runtime generates the same two overloads, and the RBS documentation on Data and Struct shows the same shape. The initialize signature describes the keyword form that Isbn overrides.

  • type loan_state = :available | :lent | :overdue | :returned is a type alias. Aliases start with a lowercase letter, classes and modules with an uppercase one.

  • interface _Store declares only what a collaborator must be able to do. An interface is not a class: nothing inherits from it, and Store::Memory conforms by listing include _Store, and any other object conforms by having matching methods. This is duck typing written down.

  • include Enumerable[Book] instantiates the generic module Enumerable with an element type.

  • each has two overloads, with and without a block. The block type () { (Book) -> void } -> void reads as "takes a block that receives a `Book`".

  • self as a return type means "the receiver’s own type", which keeps chains such as catalog.add(a).add(b) typed even in subclasses.

  • self.@config: Config? declares a class-level instance variable that may be nil before it is memoized.

The type syntax in one tour

The next file is a tour of the rest of the type syntax. It is not part of the gem and exists so that every construct is validated once.

# A tour of the RBS type syntax.
type isbn_like = String | Shelf::Isbn
type options = { title: String, ?year: Integer }
type callback = ^(String) -> void

interface _Walk[T]
  def each: () { (T) -> void } -> void
end

module Greeter : _Walk[String]
  def greet_all: () -> Array[String]
end

class Box[T < Comparable]
  attr_reader value: T
  attr_accessor label: String?
  attr_writer secret: Integer

  SIZE: Integer

  def initialize: (T value, ?label: String?) -> void
  def self.of: [U < Comparable] (U) -> Box[U]
  def max: (Box[T] other) -> Box[T]
  def pair: () -> [T, String]
  def fetch: (Symbol key, ?String default) -> (String | Integer)
  def lookup: (*String names, **Integer limits) ?{ (String) -> void } -> void
  def kind: () -> (:small | :large)
  def me: () -> self
  def new_like: () -> instance
  def to_proc: () -> ^(T) -> T
  alias same max
  private
  def internal: () -> untyped
end

class Pair[out A, in B]
end
Syntax Meaning

String | Integer

A union: either type.

String?

Shorthand for String | nil, an optional type.

A & B

An intersection: the object must be both.

[T, String]

A tuple of fixed length with a type per position.

{ title: String, ?year: Integer }

A record type for hash literals with symbol keys; ? marks a key that may be missing. In RBS 3.10 a record cannot have a catch-all **untyped; the parser rejects it with an "unexpected record key" error.

:small | :large

Literal types: a symbol, string, integer or boolean value used as a type.

^(String) -> void

A proc type, written with a caret.

untyped

The escape hatch: compatible with everything in both directions, and the way gradual typing is expressed.

top, bot

The supertype of all values and the type of no value (for example a method that always raises).

bool

true | false, the return type of predicates. boolish means "anything used for its truthiness".

void

The result is not used; callers must not depend on it.

nil

The nil value. NilClass is the class and is rarely what you want.

instance, class, self

The type of an instance of the enclosing class, of its singleton class, and of the receiver.

*String, **Integer, ?{ (String) -> void }

A rest parameter, keyword rest parameter and optional block.

?label: String?

An optional keyword parameter that also accepts nil.

[T < Comparable]

A type parameter with an upper bound; out A and in B declare variance.

Four rules explain most errors that beginners meet:

  1. Interface names start with an underscore (_Store), classes and modules do not, and aliases are lowercase. The parser enforces this.

  2. A method type always spells out the parameters and the result: () -> String takes nothing, and there is no implicit return type.

  3. Anything private is introduced with private on a line of its own, as in Ruby, and applies to the declarations that follow.

  4. Every type used must exist: a name that is not declared in any loaded directory or library is an error.

The rbs command

The rbs command works on the files in sig/ plus the core and standard-library signatures that come with the gem. -I sig adds a directory, -r library adds a standard-library signature (such as -r json), and --no-stdlib removes the defaults. The bundled standard-library directory covers json, time, logger, optparse and many more. In RBS 3.10 Set is part of the core signatures and needs no -r.

$ rbs -I sig validate
$ echo $?
0
$ rbs -I sig list | grep Shelf
::Shelf (module)
::Shelf::Error (class)
::Shelf::Isbn (class)
::Shelf::Author (class)
::Shelf::Book (class)
::Shelf::Loan (class)
::Shelf::Store (module)
::Shelf::Store::Memory (class)
::Shelf::Catalog (class)
::Shelf::Config (class)
::Shelf::Store::_Store (interface)

validate is silent when the files are consistent and exits with a non-zero status when they are not. It checks that every referenced type exists, that generics are applied with the right number of arguments, and that variance rules hold. The query commands answer questions about the resolved environment.

$ rbs -I sig ancestors ::Shelf::Catalog
::Shelf::Catalog
::Enumerable[::Shelf::Book]
::Object
::Kernel
::BasicObject

$ rbs -I sig method ::Shelf::Catalog each
::Shelf::Catalog#each
  defined_in: ::Shelf::Catalog
  implementation: ::Shelf::Catalog
  accessibility: public
  types:
      () { (::Shelf::Book) -> void } -> void    at sig/shelf.rbs:65:14...65:43
    | () -> ::Enumerator[::Shelf::Book, void]   at sig/shelf.rbs:66:14...66:42

The other subcommands are parse (syntax-check one file), ast, methods, constant, paths (the directories the environment loads), annotate (copy documentation from RDoc into signatures), subtract, diff, vendor and version.

Errors you will see

A syntax error names the exact position. This is also what happens when an interface is declared without the leading underscore, which is a common slip because classes and modules are written without one:

interface Store
  def all: () -> Array[String]
end
$ rbs parse store.rbs
store.rbs:1:10...1:15: Syntax error: expected one of interface name, token=`Store` (tUIDENT) (RBS::ParsingError)

  interface Store
            ^^^^^

The fix is interface _Store. An unknown type is a different error, reported by validate:

$ rbs -I bad2 validate
ERROR -- rbs: bad2/undef.rbs:2:12...2:16: Could not find Item (RBS::NoTypeFoundError)

    def add: (Item item) -> void
              ^^^^

A name that RBS cannot resolve inside a namespace is looked up from the innermost scope outward, so the usual fix is a missing declaration or a missing -r for a standard library.

Generating a first draft

Writing signatures for existing code by hand is slow, so rbs prototype writes drafts. prototype rb reads source and prototype runtime loads the code and inspects live classes. Both produce a lot of untyped, which is the point: they give you the shape, and you replace untyped with types.

$ rbs prototype rb fines.rb
# Late fees for overdue loans.
class Fine
  @days: untyped

  @rate: untyped

  def initialize: (untyped days, untyped rate) -> void

  def amount: () -> untyped

  def label: () -> ("overdue" | nil)
end

prototype rb guesses only from literals, so label shows the literal string type. prototype runtime sees the classes that Data.define built, which static reading cannot:

$ rbs prototype runtime -R lib/shelf.rb Shelf::Isbn
module Shelf
  class Isbn < ::Data
    def self.new: (untyped digits) -> instance
                | (digits: untyped) -> instance

    def self.[]: (untyped digits) -> instance
               | (digits: untyped) -> instance

    def self.members: () -> [ :digits ]

    def members: () -> [ :digits ]

    attr_reader digits: untyped
  end
end

Checking signatures against the running program

Signatures can be wrong, and validate only checks that they are consistent with each other. rbs test closes the gap: it wraps the methods of the classes you name and checks every call and result against the signature while your normal test suite runs. Nothing is changed in the code, and you pay the cost only in the CI job that asks for it.

$ rbs -I sig test --target 'Shelf::*' ruby -Ilib -Itest test/shelf_test.rb
Run options: --seed 3027

# Running:

....

Finished in 0.000751s, 5326.2316 runs/s, 6657.7895 assertions/s.

4 runs, 5 assertions, 0 failures, 0 errors, 0 skips

A wrong signature is caught by a test that was green before. Here the return type of describe was deliberately changed to Integer, and the same tests were run through the lower-level setup (-rrbs/test/setup with the RBS_TEST_TARGET, RBS_TEST_OPT and RBS_TEST_RAISE environment variables, which is what rbs test configures for you):

  1) Error:
ShelfTest#test_loan_pattern_matching:
RuntimeError: Neutered Exception RBS::Test::Tester::TypeError: TypeError: [Shelf::Loan#describe] ReturnTypeError: expected `::Integer` but returns `"ruby basics is lent"`

The error kinds are documented: ArgumentTypeError, BlockArgumentTypeError, ArgumentError, ReturnTypeError, BlockReturnTypeError, UnexpectedBlockError, MissingBlockError, UnresolvedOverloadingError and DuplicatedMethodDefinitionError. The --sample-size option limits how many elements of large collections are examined, and --unchecked-class excludes a class.

A Rake task keeps the cheap check in the default task:

# frozen_string_literal: true

require "rake"

desc "Validate the RBS signatures"
task :rbs do
  sh "rbs", "-I", "sig", "validate"
end

task default: :rbs

Signatures for gems you depend on: rbs collection

rbs collection is, in the words of its documentation, Bundler for RBS. It reads Gemfile.lock, downloads signatures for the locked gems from a git repository of community signatures (ruby/gem_rbs_collection by default) into .gem_rbs_collection/ and records the choice in rbs_collection.lock.yaml. It needs git and a Gemfile.lock.

$ rbs collection init
created: rbs_collection.yaml
# Download sources
sources:
  - type: git
    name: ruby/gem_rbs_collection
    remote: https://github.com/ruby/gem_rbs_collection.git
    revision: main
    repo_dir: gems

# A directory to install the downloaded RBSs
path: .gem_rbs_collection

# gems:
#   - name: GEM_NAME
#     ignore: true

rbs collection install and rbs collection update fetch from the network and were not run for this page; init was. Once installed, rbs and Steep load the signatures automatically, and rbs collection clean removes the unused ones. Add .gem_rbs_collection/ to .gitignore. Gems that ship their own sig/ directory need no collection entry.

TypeProf: inferring the types

TypeProf is, in its own words, an experimental type-level Ruby interpreter. It runs the program in the abstract, replacing each value with its class, and reports the argument and result types of every method it saw called. Its README states that it supports Ruby 3.3 or later. The input is plain Ruby, so it works even with no signatures at all. The more the entry point calls the code, the more it learns: a method that nothing calls is reported with no types, because the analysis starts from the top-level calls.

The script below has a class and two top-level calls that drive the analysis.

# frozen_string_literal: true

# Late fees for overdue loans.
class Fine
  def initialize(days, rate)
    @days = days
    @rate = rate
  end

  def amount
    @days * @rate
  end

  def label
    @days > 14 ? "overdue" : nil
  end
end

Fine.new(3, 0.25).amount
Fine.new(20, 1).label
$ typeprof fines.rb
# TypeProf 0.31.1

# fines.rb
class Fine
  def initialize: (Integer, Float | Integer) -> void
  def amount: -> (Float | Integer)
  def label: -> String?
end

Float | Integer is exactly what the program does: the first call passes a Float, the second an Integer, and amount returns whichever multiplication results. String? is the union with nil. The output is RBS, so it can be redirected to a draft signature with typeprof sig/app.rbs app.rb -o sig/app.gen.rbs, the use the documentation names as typical.

TypeProf can also point at mistakes. The next script calls a method on a value that may be nil, calls a method that does not exist, and passes the wrong number of arguments:

# frozen_string_literal: true

# A fine calculator with a deliberate mistake.
class Fine
  def initialize(days, rate)
    @days = days
    @rate = rate
  end

  def amount = @days * @rate

  def label = @days > 14 ? "overdue" : nil
end

fine = Fine.new(20, 1)
puts fine.label.upcase
puts fine.total
Fine.new(1)
$ typeprof --show-errors --show-parameter-names fines2.rb
# TypeProf 0.31.1

# fines2.rb
# (16,16)-(16,22):undefined method: nil#upcase
# (17,0)-(17,4):wrong type of arguments
# (17,10)-(17,15):undefined method: Fine#total
# (18,5)-(18,8):wrong number of arguments (1 for 2)
class Fine
  def initialize: (Integer days, Integer rate) -> void
  def amount: -> Integer
  def label: -> String?
end

Running the file on Ruby shows why the first finding matters, because label returns a String for 20 days and the crash happens later, on a different line:

$ ruby -w fines2.rb
OVERDUE
fines2.rb:17:in '<main>': undefined method 'total' for an instance of Fine (NoMethodError)

puts fine.total
         ^^^^^^

Given an existing signature, TypeProf reads it and checks the calls against it. With the shelf signature loaded, a client script that passes nil where a Book is declared gets two findings on the Loan.new calls:

# frozen_string_literal: true

def titles(catalog) = catalog.map(&:title)

def first_loan(book)
  Shelf::Loan.new(book, state: :lent)
end

titles(Shelf::Catalog.new)
first_loan(nil)
Shelf::Loan.new(nil, state: :stolen)
$ typeprof --show-errors client.rb sig/shelf.rbs
# TypeProf 0.31.1

# client.rb
# (6,14)-(6,17):wrong type of arguments
# (11,12)-(11,15):wrong type of arguments
class Object
  def titles: (Shelf::Catalog) -> Array[untyped]
  def first_loan: (nil) -> Shelf::Loan
end

Three practical cautions from using it:

  • The repository’s own configuration file says that TypeProf reports many false positives and recommends the severity hint or none for editors. Treat the CLI findings as hints, not as a gate.

  • Inference follows only the calls it can see. It does not replace writing the signature for a public API.

  • On the machine used for this page, typeprof 0.31.1 did not finish when a second, newer rbs gem (4.2.0) was installed next to the bundled 3.10.0; pinning rbs to 3.10.0 made it run. This was observed once and is stated as an observation; keep the rbs and typeprof versions in the Gemfile aligned.

The same engine runs as a language server (typeprof --lsp, set up with typeprof --init and the Ruby TypeProf extension for Visual Studio Code), which shows inferred types and errors in the editor.

rbs-inline: types in comments

rbs-inline (community, Soutaro Matsumoto) lets you write the signature in comments above the code and generates the .rbs files from them. It is a way to avoid keeping two files in step. Its README describes the project as a prototype that is not actively updated, which is a reason to weigh it before adopting it. The listing below follows the syntax in that README and was syntax-checked with ruby -wc but not processed by rbs-inline, which is not installed.

# frozen_string_literal: true

# rbs_inline: enabled

module Shelf
  # A late fee for an overdue loan.
  class Fine
    attr_reader :days #: Integer

    # @rbs days: Integer
    # @rbs rate: Float
    # @rbs return: void
    def initialize(days, rate)
      @days = days
      @rate = rate
    end

    #: () -> Float
    def amount = @days * @rate

    #: () -> String?
    def label = @days > 14 ? "overdue" : nil
  end
end

Generating the files is a single command, bundle exec rbs-inline --output lib, which writes under sig/generated (without --output it prints to the terminal). The magic comment # rbs_inline: enabled opts a file in. Note that RuboCop 1.92 reports Layout/LeadingCommentSpace on the #: form, because the comment has no space after the hash; either exclude the cop for those files or use the # @rbs form.

Steep: a static checker for RBS

Steep (community, Soutaro Matsumoto) type-checks Ruby files against the RBS signatures. It is installed with gem install steep or from the Gemfile, and configured in a Steepfile, which is Ruby code. The file below was checked with ruby -wc and RuboCop; Steep itself was not run.

# frozen_string_literal: true

target :lib do
  signature "sig"
  check "lib"

  library "json"
end

The commands, from the Steep README, are steep init (generate a Steepfile) and steep check, which prints diagnostics such as MethodDefinitionMissing with the file and position. Signatures for locked gems come through rbs collection; the README says to add library only for signatures that the collection does not manage, and the library "json" line above shows the form for a standard library.

Two things to know before adopting Steep:

  • It checks the code against the signature, so it needs signatures for the code it checks. Start by checking one directory and grow the check list.

  • Some Ruby constructs confuse any static checker. The RBS documentation gives an example that matters for this gem: Measure = Data.define(...) has the type of the anonymous class that Data.define returns, not the class named in the signature, which is reported as a type error. The documented remedies are to assign to _ (Measure = _ = Data.define(...)) or to inherit from Data.define(...), at the cost of an extra anonymous class in ancestors. The shelf gem uses the plain form, so Steep would need that adjustment.

Sorbet: types in the code

Sorbet (community, built at Stripe) takes the opposite route to RBS: the types are Ruby code. A method declares its signature with sig, a block that is run, so that the sorbet-runtime gem can check the types at run time, while the sorbet gem checks them statically. The listing below is described from the Sorbet documentation and was not run, since neither gem is installed here; it passes ruby -wc.

# frozen_string_literal: true
# typed: strict

require "sorbet-runtime"

module Shelf
  # A loan as a typed struct.
  class LoanRecord < T::Struct
    const :title, String
    prop :state, Symbol
    const :due_in, T.nilable(Integer)
  end

  # A late fee with a runtime-checked signature.
  class Fine
    extend T::Sig

    sig { params(days: Integer, rate: Float).void }
    def initialize(days, rate)
      @days = days
      @rate = rate
    end

    sig { returns(Float) }
    def amount = @days * @rate
  end
end
  • extend T::Sig adds the sig method to the class. The block uses params(...), returns(...) or void, and every sig needs a returns or void.

  • T::Struct defines a class with typed const (read-only) and prop (read-write) attributes. Construction takes keyword arguments only, and both are required unless there is a default:, a factory: or a T.nilable type. It plays the role that Data.define plays elsewhere, with types checked at run time.

  • T.nilable(Integer) is the optional type that RBS writes Integer?; T.untyped is untyped.

  • The # typed: comment sets the strictness of each file. From the Sorbet documentation: ignore (the file is not read), false (the default; only syntax, constant resolution and sig correctness are reported), true (type errors are also reported), strict (methods need a sig and constants and instance variables need explicit types) and strong (no T.untyped allowed; in beta). The ability to raise the level file by file is how Sorbet does gradual adoption.

  • The documented set-up adds sorbet (development), sorbet-runtime and tapioca to the Gemfile, runs bundle exec tapioca init to create the sorbet/ directory (which contains RBI files for gems and generated constants) and checks with srb tc.

Sorbet’s RBI files and RBS files are different formats for similar purposes; a project normally picks one of the two toolchains rather than combining them.

Gradual typing and its trade-offs

Both families are gradual: they check what is described and trust untyped for the rest. The real decision is what you want the types to do.

Concern Signatures in files (RBS with Steep) Annotations in code (Sorbet)

Ruby code stays unchanged

Yes

No, sig blocks and T:: constants are added

Types usable by non-users of the checker

Yes, the code is plain Ruby

The gem needs sorbet-runtime at run time

Runtime checking

Optional, rbs test in CI

Built in, with a performance cost per call

Ships with Ruby

RBS yes, Steep no

No

Metaprogramming, method_missing, DSLs

Describe the generated API by hand

Generated RBI files, with tapioca

Gradual by

untyped and the check list

The # typed: level per file

Reasons to adopt types at all:

  • A public API of a gem gets machine-readable documentation, and editors can complete from the signatures.

  • Refactors become safer when a checker finds every caller that breaks.

  • The cost: signature files drift from the code (which rbs test and a checker catch), and Ruby’s open classes, Data.define, refinements and generated methods need workarounds.

Reasons to stop at tests: for a small script or an application with strong tests, a Minitest suite plus RuboCop catches most defects, and neither family of tools makes a failing test unnecessary. A common middle path is RBS for the public API of a gem, rbs validate and rbs test in CI, and no static checker.

Where older material is outdated

One of the books used to cross-check this reference includes an RBS example whose interface name has no leading underscore, which the parser rejects. Beyond that case, these are the points that older tutorials commonly get out of date.

Older material may say In Ruby 4.0

An RBS interface is declared with a plain capitalised name, as a class would be.

The parser requires a leading underscore (interface _Store); without it the file fails with the "expected one of interface name" error shown above.

RBS is new or optional tooling that you install.

It is a bundled gem (3.10.0 in Ruby 4.0), with a full command line, rbs collection, prototype and runtime testing.

Value objects are written class X < Data.define(...) or by subclassing Struct.new.

X = Data.define(...) is the idiomatic form; the subclass form is a documented workaround for Steep, at the cost of an anonymous ancestor.

Set needs a require and a separate signature library.

Set is a core class in Ruby 4.0, and the RBS 3.10 core signatures include it.