Sorbet

Sorbet

  • Get started
  • Docs
  • Try
  • Community
  • GitHub
  • Blog

›Troubleshooting

Getting Started

  • Overview
  • Adopting Sorbet
  • Tracking Adoption
  • Quick Reference
  • Visual Studio Code
  • TypeScript ↔ Sorbet

Static & Runtime

  • Gradual Type Checking
  • Enabling Static Checks
  • Enabling Runtime Checks
  • RBI Files
  • CLI Quickstart
  • CLI Reference
  • Runtime Configuration

Troubleshooting

  • Troubleshooting
  • Why type annotations?
  • FAQ
  • Error Reference
  • Unsupported Ruby Features

Type System

  • sig
  • Type Annotations (non-sig)
  • T.let, T.cast, T.must, T.assert_type!, T.bind
  • Class Types (Integer, String)
  • Arrays & Hashes
  • Nilable Types (T.nilable)
  • Union Types (T.any)
  • Flow-Sensitivity (is_a?, nil?)
  • T.type_alias
  • Exhaustiveness (T.absurd)
  • T::Struct
  • T::Enum
  • T.untyped
  • Blocks, Procs, & Lambdas
  • Abstract Classes & Interfaces
  • Final Methods & Classes
  • Override Checking
  • Sealed Classes
  • T.class_of
  • T.self_type
  • T.noreturn
  • T.anything
  • T.attached_class
  • Intersection Types (T.all)
  • Generics
  • Banning untyped

Ruby & DSL Features

  • attr_reader
  • minitest

Editor Features

  • Language Server (LSP)
  • Server Status
  • LSP & Typed Level
  • Go to Definition
  • Hover
  • Autocompletion
  • Find All References
  • Code Actions
  • Outline & Document Symbols
  • Documentation Comments
  • Suggesting sigs
  • Highlighting untyped
  • sorbet: URIs

Experimental Features

  • Tuples
  • Shapes
  • Overloads
  • Requiring Ancestors
  • RBS Comments
Edit

Unsupported Ruby Features

Some features of Ruby Sorbet intentionally does not support. This doc aims to document the most common such features with explanations and alternatives.

Note: This page is not exhaustive.

If something is missing from this page, it says nothing about whether Sorbet supports it, supports it but has a bug in the implementation, or does not ever intend to support it.

When in doubt, please open an issue.

Dynamic constant references

class A
  X = 42
end

class B
  X = 'hello'
end

def takes_class(cls)
  p(cls::X)
end
takes_class(A)
takes_class(B)

def takes_inst(val)
  p(val.class::X)
end
takes_inst(A.new)
takes_inst(B.new)

Alternative

This pattern amounts to dynamic dispatch, so it’s better to use methods.

class A
  def self.x = 42
end

class B
  def self.x = 'hello'
end

def takes_class(cls)
  p(cls.x)
end
takes_class(A)
takes_class(B)

def takes_inst(val)
  p(val.class.x)
end
takes_inst(A.new)
takes_inst(B.new)

You can even get clever with this approach: methods can start with a capital letter (like constants) as long as it’s clear from context that it’s a method call, not a constant reference. So it’s possible to keep the constant’s original X name as long as you use one of these forms to call it: X(), self.X, cls::X(), etc. This can help minimize the size of the edit when refactoring code to not use constants.

As a last resort, if you absolutely cannot refactor the code to use methods (which can then be typed), you can use const_get instead:

cls.const_get(:X)

Sorbet treats all calls to const_get as untyped.

Why?

There are three main reasons.

  1. It breaks a cyclic dependency in the implementation of the type checker.

    Sorbet resolves all constant definitions and their ancestors to be able to understand the type of an expression. Sorbet cannot know the type of an expression like x.y::Z until it has first resolved the type of x.y, which requires knowing the type of x, its full class hierarchy, the methods defined in that hierarchy, and their types. All of that information requires knowing which constants are defined, which thus cannot require knowing the types of arbitrary expressions.

    Sorbet could technically allow dynamic constant references in positions where they have no impact on the ancestor hierarchy or type resolution (basically, inside method bodies excluding in type assertions like T.let). This would still exclude their use in superclasses, include, extend, T.type_alias, sig types, and types of constants.

  2. It would require treating constants like methods anyways.

    This mode of use of constants acts like dynamic dispatch. Specifically, Sorbet would need to validate the same override checking constraints that it validates for methods, meaning that Sorbet would need overridable/override syntax for constants (and likely also abstract).

    Stylistically, constants should be for static information, and methods should be the solution for dynamic dispatch.

  3. It simplifies all other constant handling inside Sorbet.

    Sorbet can assume that the scope of constant literal is always another constant literal (or nothing). This invariant substantially simplifies all other code that reads and manipulates constant references in the codebase—no need to defensively handle arbitrary code.

Constant resolution through constant scopes via inheritance

class Parent
  X = 1
end

class Child < Parent
  p(X)  # ✅ okay in both
end

p(Child::X)  # in Ruby   => ✅ 1
             # in Sorbet => ❌ error

Sorbet does not support constant resolution through inheritance when given an explicit scope.

Alternative

Use Parent::X instead of Child::X

Why?

  • Performance

    Constant resolution is one of the most performance sensitive parts of Sorbet.

  • Understandability

    In this case, the code is easier to understand by simply replacing Child::X with Parent::X. This can always be done because Sorbet does not support dynamic constant references, so the scope is always known statically.

prepend

module WillBePrepended
  def foo
    puts 'WillBePrepended#foo'
  end
end

class Example
  prepend WillBePrepended
  def foo
    puts 'Example#foo'
  end
end

Example.new.foo # => WillBePrepended#foo

Sorbet does not model inheritance relationships introduced by prepend.

Alternative

  • Usually, static support for prepend is not required, even in codebases that make heavy use of prepend.
  • In the rare cases where prepend is required, we recommend using escape hatches to work around the problems.

Why?

  • Support for prepend would force Sorbet to use more memory throughout an entire codebase, even if the codebase makes no use of prepend. Usage of prepend is far more rare than the cost it would inflict in terms of memory.

  • Supporting prepend would add implementation complexity to Sorbet’s internals. For example: consider how to do Override Checking and generic bounds checking in the presence of prepended modules.

  • Historically, Sorbet was developed at Stripe, which lints against usage of prepend.

  • Historically and maybe still today: sorbet-runtime had (has?) poor support for runtime-checked type annotations on methods defined with prepended modules.

Refinements and refine do

class C
  def foo
    puts "C#foo"
  end
end

module M
  refine C do
    def foo
      puts "C#foo in M"
    end
  end
end

using M

c = C.new

c.foo # prints "C#foo in M"

Sorbet does not support refinements.

Alternatives

  • Use an RBI file to define the methods. Sorbet will assume that the methods are defined everywhere, not just where the using directive lives. This means that Sorbet will not reject code that would not have caused problems at runtime, at the expense of not catching situations that might have.

  • Use an Escape Hatch.

  • Use a monkey patch.

Why?

  • While refinements are better than monkey patching, they still amount to monkey patching. Sorbet’s role as a type checker is not only to catch errors, but to steer people towards simpler designs.

  • Historically, Sorbet was developed at Stripe, which does not use refinements.

  • Support for refinements would add implementation complexity to Sorbet.

  • Supporting refinements would require doing program-wide work to discover and use refinements even if a codebase does not use them at all, which comes with a performance cost.

Creating method aliases to methods in parent classes

class Parent
  def defined_in_parent; end
end

class Child < Parent
  alias_method :defined_in_child, :defined_in_parent
  # Sorbet thinks this method doesn't exist ^
end

Sorbet does not support aliasing to a method defined in a parent class from a child class.

Alternative

  1. Override the parent method in the child, and have the implementation just call super:

    class Parent
      def defined_in_parent; end
    end
    
    class Child < Parent
      def defined_in_parent
        super
      end
    
      alias_method :defined_in_child, :defined_in_parent
    end
    
  2. Use RBI files to define the methods that would be defined this way:

    # -- foo.rb --
    class Parent
      def defined_in_parent; end
    end
    
    class Child < Parent
      # Hide the alias_method call from Sorbet to silence the error
      T.unsafe(self).alias_method :defined_in_child, :defined_in_parent
    end
    
    # -- foo.rbi --
    class Child < Parent
      # Define the method that will be defined with `alias_method` at runtime
      def defined_in_child; end
    end
    
  3. Use an Escape Hatch to silence errors at call sites.

Why?

Due to original design decisions made in Sorbet’s architecture, all methods are defined before inheritance information is resolved.

There is nothing fundamental or performance sensitive which requires making this choice (i.e., resolving ancestor information does not require knowing the set of defined methods). But backing out this design decision at this point would require more work than we currently believe the payoff is.

Because this is not a fundamental nor ideological limitation, it’s possible this feature may gain support in the future.

Multi-line calls to to keyword-named methods with trailing .

# 1. single-line method call
x.end() # ok

# 2. multi-line method call, leading `.`
x
  .end() # ok

# 3. multi-line method call, trailing `.`
x.
  end() # not ok

Ruby allows methods to be defined with names that are nominally reserved for keywords—like the method called end() above, even though there is a keyword called end.

For methods which share a name with a Ruby keyword, Sorbet does not allow a newline to appear between the . token and the method name.

Alternative

Use a leading . for chained multi-line method calls, instead of a trailing ..

Note: newer versions of irb and pry support the leading . syntax about as well as the trailing . syntax. In old versions of irb and pry, the REPLs did a poor job of detecting multi-line pastes, and would eagerly evaluate each line instead of waiting for the full paste and evaluating the entire snippet. Newer versions of irb and pry detect the terminal emulator’s bracketed paste functionality and pause evaluation until the paste finishes.

Why?

One of the most common syntax errors in a Ruby program looks like this:

def example(x)
  x.
end

At a glance, it looks like the syntax error is that the user has forgotten or is in the process of typing the method name after the x.. But to the Ruby parser, the method name was provided—it’s a method named end. Instead, the syntax error the Ruby parser sees is that the user forgot to terminate their method definition with an end keyword after the last line.

In order to make error messages and autocompletion suggestions better, Sorbet reverts to treating the characters end as a keyword, not a method name, after it sees a sequence of . followed by \n. This is a small change to the Ruby grammar with a small cost to implement, for a large improvement in developer ergonomics when working in an IDE, with a straightforward workaround.

Note that this applies to all keywords, not just end. For a complete list of Ruby keywords, see the Ruby docs.

For more, see #1993.

Tracking code loading order

# -- a.rb --
class A; end

# -- b.rb --
# ... does not require/autoload `a.rb` ...

puts(A) # => in Ruby: ❌ NameError
        # => in Sorbet: ✅

Sorbet does not track require, require_relative, or autoload declarations.

This means that Sorbet does not report errors for these errors or warnings from the Ruby VM:

  • Accessing constants that are defined somewhere in the project but haven’t been loaded yet when the code runs.
    • This includes constants that appear to resolve statically, but which rely on an assumption that all include and extend lines will have run by the time a constant is accessed.
  • Reassigning a constant (X = 1; X = 2), which are warnings in the Ruby VM, so long as the declared type of the constant is the same in both definitions.
  • Redefining a method (def f; end; def f; end), so long as the arity of the method is the same in both definitions
  • Accessing an instance variable before it’s been initialized.
  • etc.

Alternative

Use runtime code loading mechanisms (e.g. tests or other runtime checks) to make sure that code can be loaded, possibly opting into more verbose warning checking in the Ruby VM.

Why?

  • Different projects use different code loading paths for gems. Rather than have Sorbet reimplement the algorithm Ruby/rbenv/rvm/etc. use to load gems out of system directories, Sorbet requests that all gems are declared with RBI files included in the args specified at the command line.

  • Certain require statements will be computed dynamically, either behind if/else expressions, inside method calls, or even with non-static string arguments. Sorbet cannot analyze these—the problem would reduce to having Sorbet statically evaluate Ruby code.

  • Many projects, especially Rails projects, use a path-based autoloader, like zeitwerk. Projects using code loaders like this typically do not make their autoload statements visible to Sorbet at all: the autoload statements are dynamically generated by the project at runtime.

  • Sorbet does not track whether or in what order code loads. For example, a project might define two versions of a file: one which is loaded on old versions of Ruby, one which is loaded on newer versions of Ruby. The files might define the same classes and methods, but with different implementations. The project itself knows that at runtime only one of these will be loaded, but there would be no way to indicate that to Sorbet.

Dynamically evaluating code in another class or module

x = Class.new do
  def foo; end
end

MyClass.class_eval do
  def foo; end
end

Constructs like Class.new, Module.new, class_exec, module_eval, module_exec, instance_eval, instance_exec, and others which dynamically evaluate code to define methods or constants in the context of another class or module is not supported. When Sorbet sees a method defined in a block, regardless of what the block does, it defines it in the enclosing lexical scope, ignoring any dynamic behavior. This means that methods defined dynamically but at the lexical top-level of a file will appear no different than a method defined literally at the top-level, i.e., on ::Object as a private method. This extends to user-defined or library-defined methods which wrap these methods’ functionality.

As a limited special case, Sorbet supports A = Class.new { ... } and M = Module.new { ... }, where the dynamically created module is assigned directly to a constant.

Alternative

  1. Hide the definitions from Sorbet using define_method:

    x = Class.new do
      define_method(:foo) do; end
    end
    
    MyClass.class_eval do
      define_method(:foo) do; end
    end
    

    This is imperfect, as Sorbet will still typecheck the bodies of those dynamically-defined methods as if they were defined outside the enclosing Class.new or class_eval scope, etc. To silence those errors, consider using T.bind to forcibly correct the type of self inside those define_method blocks, or T.bind(self, T.untyped) to silence the errors.

    MyClass.class_eval do
      define_method(:foo) do
        T.bind(self, MyClass)
        self.method_on_my_class
      end
    end
    
  2. Move these metaprogramming patterns to a file that is # typed: ignore (more about typed strictness levels). Sorbet parses and defines methods for all def-defined methods even in # typed: false files, so only # typed: ignore prevents these methods from being defined.

    Be careful when downgrading a file to # typed: ignore, because it will cause the entire file to be ignored, including the desirable parts. It’s often best to refactor the code so that only the dynamic evaluation code lives in an ignored file. For example, something like:

    # -- extn/my_class.rb --
    # typed: ignore
    MyClass.class_eval do
      def foo; end
    end
    
    # -- main.rb --
    require_relative 'extn/my_class'
    
    # ...
    

    It can also be helpful to create RBI files to declare to Sorbet what would otherwise be ignored if there were only the # typed: ignore Ruby source file.

Why?

Sorbet does not know the meaning of method calls like Class.new or MyClass.class_eval at the time that it is discovering defined methods—that would be a logical cycle. Sorbet might not yet have seen the class_eval definition (it could be overridden in that class to do something else). In other pathological cases, like x.class_eval, Sorbet would need to know what the variable x refers to, which requires full type inference, which requires knowing type and method information, an even deeper cycle.

It would also be too restrictive to prevent defining methods inside blocks altogether: methods defined in blocks can be fine sometimes. An example, from the thor gem:

class HelloCLI < Thor
  def hello(name) = greet(name)

  no_commands do
    def greet(name)
      puts "Hello, #{name}!"
    end
  end
end

In this case, the method defined in the no_commands block is fine to be treated as if it were defined in the lexically enclosing scope, and thus this is what Sorbet does. That choice is sometimes wrong, and it can fail in confusing ways, but it at least is not overly restrictive, which generally helps when adopting Sorbet in new codebases.

← Error Referencesig →
  • Dynamic constant references
    • Alternative
    • Why?
  • Constant resolution through constant scopes via inheritance
    • Alternative
    • Why?
  • prepend
    • Alternative
    • Why?
  • Refinements and refine do
    • Alternatives
    • Why?
  • Creating method aliases to methods in parent classes
    • Alternative
    • Why?
  • Multi-line calls to to keyword-named methods with trailing .
    • Alternative
    • Why?
  • Tracking code loading order
    • Alternative
    • Why?
  • Dynamically evaluating code in another class or module
    • Alternative
    • Why?

Get started · Docs · Try · Community · Blog · Twitter