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 tountyped· Coming from PHPStan · The plugin escape hatch
When you need RBS
Section titled “When you need RBS”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-mismatchto fire on argument-shape errors (in-sourcedefdoes NOT enforce parameter contracts; only RBS-declared methods do). - You want
def.return-type-mismatchto 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).
A first sig
Section titled “A first sig”In a fresh project:
my-app/├── lib/│ └── slug.rb└── sig/ └── slug.rbs # ← your sigclass Slug def normalise(id) id.downcase.gsub(/\s+/, "-") endendclass Slug def normalise: (String) -> StringendDrop 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.
When the RBS shape is too wide
Section titled “When the RBS shape is too wide”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) -> StringendNow:
s = Slug.new.normalise("Hello World")# s: non-empty-lowercase-strings.empty? # Constant<false> — provens.size # positive-int — provens == "hello-world" # bool — equality narrowing appliesThe .rbs file is still valid RBS — %a{...} is the RBS
annotation syntax. Steep / typeprof / ruby-lsp see a comment;
Rigor sees a tightening.
The directive grammar
Section titled “The directive grammar”RBS::Extended lives at
docs/type-specification/rbs-extended.md.
The five directives:
| Directive | Says |
|---|---|
%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 names —
String,Integer,::Foo::Bar. - Imported refinement names —
non-empty-string,lowercase-string,numeric-string,int<5, 10>,non-empty-array[Integer],literal-string, … - Negation
~T—~lowercase-stringmeans “non-lowercase-string.”
Refinement names
Section titled “Refinement names”The full catalogue is in
docs/type-specification/imported-built-in-types.md.
A short reference:
| Family | Names |
|---|---|
| Empty / non-empty | non-empty-string, non-empty-array[T], non-empty-hash[K, V] |
| Integer ranges | positive-int, non-negative-int, negative-int, non-positive-int, non-zero-int, int<min, max> |
| String predicates | lowercase-string, uppercase-string, numeric-string, decimal-int-string, octal-int-string, hex-int-string, literal-string |
| Paired complements | non-lowercase-string, non-uppercase-string, non-numeric-string |
| Composed | non-empty-lowercase-string, non-empty-uppercase-string, non-empty-literal-string |
| Shape projections | pick_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”. |
Worked example: an assertion gate
Section titled “Worked example: an assertion gate”class Validator %a{rigor:v1:assert: x is non-empty-string} def assert_non_empty: (String x) -> voidenddef configure(host) Validator.new.assert_non_empty(host) # host: non-empty-string after this call host.size # positive-int — provenendThe runtime side is whatever assert_non_empty does (raise
on empty, log, …) — Rigor only reads the directive.
Worked example: a type predicate
Section titled “Worked example: a type predicate”class Range %a{rigor:v1:predicate-if-true: value is Integer} def integer?: (untyped value) -> boolenddef double_if_int(value) if (1..10).integer?(value) # value: Integer in the truthy branch value * 2 else value endendThis 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.
Worked example: parameter override
Section titled “Worked example: parameter override”class Slug %a{rigor:v1:param: id is non-empty-string} def normalise: (String id) -> StringendThis has two effects:
- Call-site checking.
Slug.new.normalise("")is now acall.argument-type-mismatchbecauseConstant<"">does not satisfynon-empty-string. - Body-side narrowing. Inside the method body of
normalise, the parameteridisnon-empty-string. Soid.empty?reduces toConstant<false>andid.sizereduces topositive-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?endFileLoader.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.
Where annotations belong
Section titled “Where annotations belong”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) -> StringendYou 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 endend
AscDesc.new.ascdesc(:bad)# => error: argument type mismatch at parameter `asc_or_desc' of# `ascdesc' on AscDesc: expected :asc | :desc, got :badThe # @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:
plugins: - rigor-rbs-inlinePer file, opt in with the upstream # rbs_inline: enabled
magic comment at the top — files without it are unaffected.
Notes:
- The core
rigortypeanalyzer stays zero-runtime-dependency (ADR-0). Therbs-inlineupstream 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
defproduces 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:infodiagnostic; 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.
Falling back to untyped
Section titled “Falling back to untyped”When a method’s signature involves a type RBS cannot express,
the conservative thing to do is untyped:
def deserialize: (String) -> untypeduntyped 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 PHPDoc | Rigor RBS::Extended | Effect |
|---|---|---|
@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:
class Asserts %a{rigor:v1:assert: x is ~nil} def self.not_nil: (untyped x) -> voidenddef configure(maybe) Asserts.not_nil(maybe) # maybe: (~nil), so .upcase resolves on the narrowed type maybe.upcaseendSelf-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!: () -> voidendRigor’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.
What’s next
Section titled “What’s next”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.