Coexisting with Sorbet
If your project already uses Sorbet,
the rigor-sorbet plugin
lets Rigor read your existing sig blocks, RBI files, and
T.let / T.cast / T.must / T.unsafe assertions as type
sources. You do not have to rewrite anything in RBS to start
running rigor check alongside srb tc.
This chapter is for users arriving from a Sorbet-using project. If you have never used Sorbet, you can skip it; the core handbook material in chapters 1–9 covers Rigor’s native RBS-based path.
In this chapter What gets translated · Sorbet type vocabulary · Inline assertions (
T.let/T.must/ …) · RBI files ·# typed:sigils · Tapioca DSL mixins ·T.absurdexhaustiveness · Tier ordering on conflict · Migration patterns · What it doesn’t replace
What gets translated
Section titled “What gets translated”Given a method preceded by a sig block:
class Slug extend T::Sig
sig { params(name: String).returns(String) } def normalise(name) name.downcase.gsub(/\s+/, "-") end
sig { returns(Integer) } def self.default_length 32 endendRigor lifts the parsed sig at every call site, so chained calls resolve through the analyzer’s normal dispatch:
slug = Slug.newslug.normalise("Alice").upcase # ✓ String#upcase resolvesSlug.default_length.even? # ✓ Integer#even? resolvesNo .rbs file required. The plugin walks every Ruby file
under paths: (and every .rbi file under sorbet/rbi/ —
see “RBI files” below), pairs each sig { ... } block with
the def immediately following it, and contributes the
return type at the matching call sites.
The Sorbet type vocabulary
Section titled “The Sorbet type vocabulary”The plugin translates the dense middle of Sorbet’s type DSL.
Most everyday sigs land precisely; rare or
class-introspection-heavy forms degrade to Dynamic[Top].
| Sorbet form | Rigor representation |
|---|---|
Integer etc. | Nominal["Integer"] |
::Foo::Bar | Nominal["Foo::Bar"] |
T.untyped | Dynamic[Top] |
T.anything | Top |
T.noreturn | Bot |
T.nilable(X) | Union[X, Constant<nil>] |
T.any(A, B, ...) | Union[A, B, ...] |
T.all(A, B, ...) | Intersection[A, B, ...] |
T::Boolean | Union[Constant<true>, Constant<false>] |
T::Array[E] | Nominal["Array", [E]] |
T::Hash[K, V] | Nominal["Hash", [K, V]] |
T::Set[E] | Nominal["Set", [E]] |
T::Range[E] | Nominal["Range", [E]] |
T::Enumerable[E] | Nominal["Enumerable", [E]] |
T::Class[T] | Singleton[T-class-name] (lossy) |
T.class_of(C) | Singleton[C] |
[A, B] (tuple in sig) | Tuple[A, B] |
{a: A, b: B} | HashShape{a: A, b: B} (closed) |
Anything outside this table — T.proc, T.attached_class,
T.self_type, T.type_parameter, T::Struct / T::Enum
subclasses — silently degrades to Dynamic[Top] for now.
Inline type assertions
Section titled “Inline type assertions”Sorbet’s T.let / T.cast / T.must / T.unsafe
expressions are recognised at every call site, not only inside
sig blocks:
counter = T.let(0, Integer) # widens Constant<0> to Integercounter.even? # ✓ Integer#even? resolves
T.cast(some_value, String).upcase # ✓ String#upcase resolves
maybe = T.let(nil, T.nilable(Integer))T.must(maybe).bit_length # ✓ nil stripped → Integer # then Integer#bit_length resolves
T.unsafe(opaque).any_method_at_all # ✓ silenced — return is Dynamic[Top]T.must_because(expr, "explanation") is recognised as an
alias of T.must — the static behaviour is identical (strip
nil); the second-argument string is informational only.
T.reveal_type(expr) returns expr unchanged at runtime AND
surfaces the inferred static type as a
plugin.sorbet.reveal-type :info diagnostic at the call
site, so chained calls keep working while you eyeball what
the analyzer sees:
n = T.let(3, Integer)T.reveal_type(n).even? # info: T.reveal_type inferred type: Integer # ✓ Integer#even? still resolvesT.assert_type!(expr, T) is T.cast plus a static subtype
check. The call returns the asserted type so chained calls
resolve through it; if the inferred type is provably
incompatible (Inference::Acceptance.accepts(...) returns
:no), the plugin emits plugin.sorbet.assert-type-mismatch
as :error. Gradual consistency rules apply — Dynamic[Top]
inferred types and :maybe-compatible shapes are silenced
because the runtime check covers them.
T.assert_type!("hello", Integer) # error: provably incompatibleT.assert_type!(some_obj, String) # silent: trust the userT.bind(self, T) narrows self to T for the rest of the
current scope (typically a block body):
arr.each do |x| T.bind(self, MyHelper) do_something(x) # ✓ self is now MyHelper for the rest of this blockendThe narrowing is implemented via the engine’s plugin-side
post_return_facts wiring — the same substrate any future
PHPStan-style Type-Specifying Extension plugin would use to
narrow argument variables after a custom assertion call.
T.bind rejects non-self first arguments silently (matches
Sorbet’s contract — bind is self-only).
RBI files
Section titled “RBI files”The plugin walks sorbet/rbi/**/*.rbi recursively by default
and treats each .rbi as Ruby source. The standard Tapioca
subdirectories (gems/, annotations/, dsl/, shims/)
all participate as a side effect of recursing into the parent
root. Override the location via config.rbi_paths: in
.rigor.yml, or set it to [] to opt out:
plugins: - gem: rigor-sorbet config: rbi_paths: [] # disable RBI loading # rbi_paths: ["sorbet/rbi", "vendor/rbi"] # add a vendored treeProject sigs (.rb files under paths:) and RBI sigs
(.rbi files under rbi_paths:) feed the same per-run
catalog, so a method declared in either source resolves the
same way at the call site.
Sorbet # typed: sigils
Section titled “Sorbet # typed: sigils”The plugin reads Sorbet’s # typed: magic comment from the
top of each file. Behaviour depends on the enforce_sigil
config knob (default true):
| Sigil | enforce_sigil: true (default) | enforce_sigil: false |
|---|---|---|
# typed: ignore | Skipped entirely; no sigs / parse errors recorded. | Same. |
no sigil / false | Walked for parse-error diagnostics, but sigs are NOT recorded. | Sigs recorded. |
# typed: true+ | Sigs recorded. | Sigs recorded. |
The default mirrors Sorbet’s own contract: types aren’t
enforced at # typed: false, so Rigor doesn’t surface
narrowing from those files either. Set enforce_sigil: false
in the plugin config to opt into the pre-gate behaviour
(every parseable file’s sigs land in the catalog regardless
of sigil).
Assertion recognisers (T.let, T.cast, T.must,
T.must_because, T.unsafe, T.reveal_type,
T.assert_type!, T.bind) are NOT gated by
enforce_sigil. The user wrote those calls deliberately, so
they fire regardless of the file’s sigil.
Sorbet-strict’s “every method must have a sig” requirement
and strong-mode’s T.untyped rejection are intentionally NOT
mirrored. Those checks live with srb tc. Rigor’s own
severity_profile setting in .rigor.yml covers the
analogous filtering.
Tapioca DSL — the mixin pattern
Section titled “Tapioca DSL — the mixin pattern”Tapioca’s standard DSL RBI shape declares sigs on a generated
module that is included / extended into the host class:
class Post include GeneratedAttributeMethods module GeneratedAttributeMethods sig { returns(::String) } def body; end endendThe plugin records the sig under the module’s qualified name
during the walk and lifts it to the host class at lookup
time. So post.body correctly resolves through
Post::GeneratedAttributeMethods#body — no manual
flattening required, and the same trick works for
hand-written shims under sorbet/rbi/shims/ and community
annotations under rbi-central.
extend M correctly lifts M’s instance methods to the
extending class’s singleton side, matching Ruby’s runtime
behaviour:
class Post extend GeneratedClassMethods module GeneratedClassMethods sig { params(id: Integer).returns(Post) } def find(id); end endendPost.find(42) resolves through the extended module’s
instance side.
T.absurd exhaustiveness
Section titled “T.absurd exhaustiveness”T.absurd(x) is Sorbet’s idiom for case/when exhaustiveness:
“if I got here, the type system has lost the plot.” The
plugin treats every T.absurd call as Bot (the empty
type — no possible value) AND raising, so the engine’s
existing flow analysis treats code after the call as
unreachable:
case xwhen A then handle_a(x)when B then handle_b(x)else T.absurd(x) # asserts the else branch is unreachableendWhen the discriminant is fully exhausted, the T.absurd
call sits in dead code and contributes nothing. When a case
branch is missing, the discriminant’s type at the T.absurd
call still has admissible inhabitants, and the plugin
surfaces plugin.sorbet.absurd-reachable as a warning:
demo.rb:42:5: warning: `T.absurd` is reachable: the discriminant did not narrow to `T.noreturn`. Either add the missing case branch above the `else`, or remove the `T.absurd(...)` call. [plugin.sorbet.absurd-reachable]The detection’s accuracy follows Rigor’s flow-sensitive
narrowing — is_a? / kind_of? / nil? work precisely;
narrowing over symbol enums is less precise as of v0.1.3,
so fully-exhausted symbol cases may emit false-positive
warnings until the engine’s case narrowing improves.
Tier ordering — what wins on conflict
Section titled “Tier ordering — what wins on conflict”When a method has both a Sorbet sig and an RBS sig, RBS
wins. Sorbet sigs sit at Rigor’s plugin tier:
- Precision tiers — constant fold, shape dispatch, block fold, etc.
- Plugin contributions — including
rigor-sorbet’s sig and assertion translations. - RBS-backed dispatch — project
sig/,RBS::Inline, bundled stdlib. - Dependency-source inference (ADR-10’s opt-in walker).
- User-class fallback (
Object/Classancestors).
The contribution merger (a v0.1.0 substrate documented in
docs/internal-spec/flow-contribution-merger.md)
keeps RBS authoritative on conflict — the Sorbet sig is
allowed to refine but not contradict it. Users who want
their Sorbet sig to override should remove the conflicting
RBS, not the other way around. The reverse direction
(Sorbet wins) would let third-party-DSL annotations
override authored RBS, which inverts the trust model.
Migration patterns
Section titled “Migration patterns”The plugin is designed for gradual coexistence, not a forced migration. Three common shapes:
- Run both static checkers side by side.
srb tckeeps producing its diagnostics;rigor checkproduces its own. They overlap on shape errors and complement each other on what each finds — Sorbet coversT.let/T.cast/ RBI more deeply; Rigor covers literal-string narrowing, refinement carriers, plugin DSLs, and dependency-source inference. - Sorbet for sigs, Rigor for narrowing. Authoritative
sigs stay in
sig { ... }blocks (or the sorbet-runtime-friendly RBI tree); Rigor reads them as input and adds its own narrowing on top. - Sorbet → RBS over time. New code lands as RBS; existing Sorbet sigs stay until the surrounding subsystem changes. The plugin keeps running while the Sorbet surface shrinks.
What the plugin doesn’t replace
Section titled “What the plugin doesn’t replace”Rigor’s rigor-sorbet adapter is input-side only. It
reads Sorbet’s syntax and translates the vocabulary; it does
not run Sorbet’s type checker, doesn’t ship
sorbet-runtime, and doesn’t enforce Sorbet’s runtime
guarantees. If you remove sorbet and sorbet-runtime from
your Gemfile, the plugin keeps reading the sigs (the
adapter’s mini-interpreter doesn’t load Sorbet) but T.let /
T.cast / T.must / T.unsafe calls will raise
NameError at runtime unless you keep at least the runtime
gem (or stub the four singleton methods on a top-level T
constant — the plugin’s demo does this for its own
unit tests).
Where to go next
Section titled “Where to go next”- The full feature matrix and architectural surface live in
plugins/rigor-sorbet/README.md. - The design rationale + slice plan is at
docs/adr/11-sorbet-input-adapter.md. - The cross-checker triage report at
docs/notes/20260503-steep-cross-check-triage.mdshows how Rigor’s analyzer routinely surfaces sig drift that other static checkers miss — useful when comparing what each tool finds in practice.
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.