Skip to content

RBS and RBS::Extended

When Rigor’s inference cannot prove a type, the next escape hatch is RBS — Ruby’s signature language. When RBS cannot express the precise contract you want, RBS::Extended adds a small annotation surface on top.

This chapter covers both, in the order you usually reach for them.

In this chapter When you need RBS · A first sig · When the RBS shape is too wide · The directive grammar · Refinement names · Worked examples — assertion gate · type predicate · parameter override · Overrides the runtime can’t enforce · Where annotations belong · Inline RBS (rigor-rbs-inline) · Falling back to untyped · Coming from PHPStan · The plugin escape hatch

You probably need to add an RBS file when:

  • The method body’s return type depends on an external gem Rigor’s bundled stdlib does not cover.
  • You want call.argument-type-mismatch to fire on argument-shape errors (in-source def does NOT enforce parameter contracts; only RBS-declared methods do).
  • You want def.return-type-mismatch to fire when a body’s inferred return drifts from the declared return.
  • A future RBS-aware tool (Steep, ruby-lsp) will read the same file and benefit from the contract.

You probably do not need RBS when:

  • The method is private to your project, the body is short, and Rigor already infers the right return type.
  • The method is just a wrapper around a method that already has a sig (Rigor walks the body and propagates).

In a fresh project:

my-app/
├── lib/
│ └── slug.rb
└── sig/
└── slug.rbs # ← your sig
lib/slug.rb
class Slug
def normalise(id)
id.downcase.gsub(/\s+/, "-")
end
end
sig/slug.rbs
class Slug
def normalise: (String) -> String
end

Drop the .rbs file in sig/ and Rigor picks it up automatically — no .rigor.yml change required. The default config has signature_paths: [sig].

After that, this code:

Slug.new.normalise(42)

fires call.argument-type-mismatch: 42 is an Integer, the parameter is String.

The Slug example’s runtime always returns a non-empty, lowercase string — but the RBS sig only says String. If you want Rigor to know the narrower fact, attach an RBS::Extended annotation:

class Slug
%a{rigor:v1:return: non-empty-lowercase-string}
def normalise: (String) -> String
end

Now:

s = Slug.new.normalise("Hello World")
# s: non-empty-lowercase-string
s.empty? # Constant<false> — proven
s.size # positive-int — proven
s == "hello-world" # bool — equality narrowing applies

The .rbs file is still valid RBS%a{...} is the RBS annotation syntax. Steep / typeprof / ruby-lsp see a comment; Rigor sees a tightening.

RBS::Extended lives at docs/type-specification/rbs-extended.md. The five directives:

DirectiveSays
%a{rigor:v1:return: <type>}Tighten the method’s return type.
%a{rigor:v1:param: <name> is <type>}Tighten a parameter’s accepted type at the call site, AND narrow the local in the body.
%a{rigor:v1:assert: <name> is <type>}After this method returns, the named local in the caller’s scope is <type>.
%a{rigor:v1:predicate-if-true: <name> is <type>}When this method returns truthy, the named local in the caller’s scope is <type>. (Symmetric predicate-if-false.)
%a{rigor:v1:assertion-on: <name>}Mark the method as an assertion gate — the body’s last expression’s type becomes a fact about <name>.

The <type> slot accepts:

  • RBS class namesString, Integer, ::Foo::Bar.
  • Imported refinement namesnon-empty-string, lowercase-string, numeric-string, int<5, 10>, non-empty-array[Integer], literal-string, …
  • Negation ~T~lowercase-string means “non-lowercase-string.”

The full catalogue is in docs/type-specification/imported-built-in-types.md. A short reference:

FamilyNames
Empty / non-emptynon-empty-string, non-empty-array[T], non-empty-hash[K, V]
Integer rangespositive-int, non-negative-int, negative-int, non-positive-int, non-zero-int, int<min, max>
String predicateslowercase-string, uppercase-string, numeric-string, decimal-int-string, octal-int-string, hex-int-string, literal-string
Paired complementsnon-lowercase-string, non-uppercase-string, non-numeric-string
Composednon-empty-lowercase-string, non-empty-uppercase-string, non-empty-literal-string
Shape projectionspick_of[T, K], omit_of[T, K], partial_of[T], required_of[T], readonly_of[T] — derive new HashShape / Tuple carriers from existing ones. See chapter 4 § “Deriving new shapes”.
class Validator
%a{rigor:v1:assert: x is non-empty-string}
def assert_non_empty: (String x) -> void
end
def configure(host)
Validator.new.assert_non_empty(host)
# host: non-empty-string after this call
host.size # positive-int — proven
end

The runtime side is whatever assert_non_empty does (raise on empty, log, …) — Rigor only reads the directive.

class Range
%a{rigor:v1:predicate-if-true: value is Integer}
def integer?: (untyped value) -> bool
end
def double_if_int(value)
if (1..10).integer?(value)
# value: Integer in the truthy branch
value * 2
else
value
end
end

This is the supported way to teach Rigor about a custom type-predicate method that the engine’s built-in is_a? / nil? rules cannot recognise.

class Slug
%a{rigor:v1:param: id is non-empty-string}
def normalise: (String id) -> String
end

This has two effects:

  1. Call-site checking. Slug.new.normalise("") is now a call.argument-type-mismatch because Constant<""> does not satisfy non-empty-string.
  2. Body-side narrowing. Inside the method body of normalise, the parameter id is non-empty-string. So id.empty? reduces to Constant<false> and id.size reduces to positive-int.

When you need a parameter override the runtime cannot enforce

Section titled “When you need a parameter override the runtime cannot enforce”

Sometimes the runtime function does NOT raise on bad input — it returns nil, returns a default, or swallows the error. Rigor’s param: directive still tightens the call-site contract:

class FileLoader
%a{rigor:v1:param: path is non-empty-string}
def load: (String path) -> String?
end

FileLoader.new.load("") fires call.argument-type-mismatch even though at runtime load would fail gracefully. The directive expresses what callers should pass, not what the body enforces.

Put RBS::Extended annotations on the same def they refine, inside the same .rbs file. Group them above the method:

class Slug
%a{rigor:v1:return: non-empty-string}
%a{rigor:v1:param: id is non-empty-string}
def normalise: (String id) -> String
end

You cannot put these %a{rigor:v1:…} directives inside a .rb file. The directives only fire when read from RBS — that is a design choice (see ADR-5, the robustness principle: strict on returns, lenient on parameters).

Inline RBS in Ruby source — the rigor-rbs-inline plugin

Section titled “Inline RBS in Ruby source — the rigor-rbs-inline plugin”

A separate, opt-in plugin lets you write method types directly above the def in your Ruby file, using the rbs-inline comment vocabulary upstream defines:

# rbs_inline: enabled
class AscDesc
# @rbs asc_or_desc: :asc | :desc
def ascdesc(asc_or_desc)
asc_or_desc
end
end
AscDesc.new.ascdesc(:bad)
# => error: argument type mismatch at parameter `asc_or_desc' of
# `ascdesc' on AscDesc: expected :asc | :desc, got :bad

The # @rbs name: T doc-style annotation, the #: () -> T inline method-type comment, # @rbs return: T, attribute #: casts, # @rbs @ivar: T, # @rbs override, and # @rbs! raw RBS embedding all work — anything upstream rbs-inline accepts flows through to Rigor’s RBS environment as if you had hand- written the equivalent .rbs file.

This is not RBS::Extended. The # @rbs comments are upstream rbs-inline’s grammar; the plugin transcribes them to ordinary RBS at env build. RBS::Extended %a{rigor:v1:…} directives, by contrast, are Rigor-only annotations that live in .rbs files (see the rest of this chapter for those).

To enable it, add the plugin gem to your bundle and list it:

.rigor.yml
plugins:
- rigor-rbs-inline

Per file, opt in with the upstream # rbs_inline: enabled magic comment at the top — files without it are unaffected.

Notes:

  • The core rigortype analyzer stays zero-runtime-dependency (ADR-0). The rbs-inline upstream library is a dependency of the plugin gem, not of the core, so projects that don’t opt in pay nothing.
  • A bare top-level def produces no RBS output through upstream rbs-inline. Wrap method definitions in a class or module when you need the annotation to take effect.
  • A failed rbs-inline parse surfaces as a source-rbs-synthesis-failed :info diagnostic; the file falls back to no inline-RBS contribution and analysis continues.

Full plugin documentation, configuration options (including the require_magic_comment: false host-context override the browser playground uses), and the caching contract: plugins/rigor-rbs-inline/README.md.

When a method’s signature involves a type RBS cannot express, the conservative thing to do is untyped:

def deserialize: (String) -> untyped

untyped is a contract-free hatch — every method exists on it, every argument shape is acceptable. Rigor’s diagnostics stay silent on untyped receivers. Use it for legitimately dynamic boundaries (deserialisation, eval, plugin entry points). The static analysis you lose is made up by the honesty of admitting “this could be anything.”

Coming from PHPStan? The @phpstan-assert family

Section titled “Coming from PHPStan? The @phpstan-assert family”

If you are familiar with PHPStan’s PHPDoc annotations, Rigor’s RBS::Extended directives map directly onto the post-return / conditional narrowing primitives PHPStan calls “asserts” and “type-specifying functions.” The behaviour is identical:

“After this method returns, the named argument is T.”

That is @phpstan-assert in PHPStan and %a{rigor:v1:assert:} in Rigor.

PHPStan PHPDocRigor RBS::ExtendedEffect
@phpstan-assert T $x%a{rigor:v1:assert: x is T}After this method returns normally, the caller’s x is T.
@phpstan-assert-if-true T $x%a{rigor:v1:predicate-if-true: x is T}If this method returns truthy, the caller’s x is T.
@phpstan-assert-if-false T $x%a{rigor:v1:predicate-if-false: x is T}If this method returns falsey, the caller’s x is T.
@phpstan-assert !T $x%a{rigor:v1:assert: x is ~T}After this method returns, the caller’s x is not T (negation form).
@phpstan-assert-if-true !T $x%a{rigor:v1:predicate-if-true: x is ~T}Conditional negation. Symmetric with predicate-if-false.

Worked example — the canonical “assertNotNull” pattern from PHPStan’s docs:

sig/asserts.rbs
class Asserts
%a{rigor:v1:assert: x is ~nil}
def self.not_nil: (untyped x) -> void
end
lib/configure.rb
def configure(maybe)
Asserts.not_nil(maybe)
# maybe: (~nil), so .upcase resolves on the narrowed type
maybe.upcase
end

Self-targeted forms are supported too — the PHPStan analogue would be a method on $this that narrows $this. Name the receiver with self:

class Connection
%a{rigor:v1:assert: self is Connected}
def assert_connected!: () -> void
end

Rigor’s directive grammar covers what PHPStan ships in the @phpstan-assert* family. The directives only fire from RBS (per ADR-5: strict on returns, lenient on parameters); in PHPStan-land you can also write @phpstan-assert in PHPDoc directly above the function — Rigor’s equivalent is the same RBS file’s def line.

If you need plugin-side equivalents (when the assertion is recognised by call shape rather than by sig — PHPStan’s “Type-Specifying Extensions”), see Chapter 9. The plugin contract surfaces the same Fact(target_kind: :self) and Fact(target_kind: :parameter) carriers that the directives use, so a plugin author writes the equivalent of a PHPStan StaticMethodTypeSpecifyingExtension from Ruby.

When RBS cannot help — the plugin escape hatch

Section titled “When RBS cannot help — the plugin escape hatch”

When a method’s behaviour depends on the shape of its arguments at runtime (Lisp.eval([:+, 1, 2]) returns Integer, but Lisp.eval([:<, 1, 2]) returns bool), no RBS sig can express the relationship. That is what plugins are for — see Chapter 9 and the examples/ directory.

Chapter 8 covers the rule catalogue — what each diagnostic means, when it fires, and how to suppress it when it is wrong or noisy.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.