Flow-Sensitive Typing
Sorbet implements a control flow-sensitive type system. It models control flow through a program and uses it to track the program’s types more accurately.[1]
Example
extend T::Sig
sig { params(x: T.nilable(String), default: String).returns(String) }
def maybe(x, default)
# (1) Outside the if, x is either nil or a String
T.reveal_type(x) # => Revealed type: `T.nilable(String)`
if x
# (2) At this point, Sorbet knows `x` is not nil
T.reveal_type(x) # => Revealed type: `String`
x
else
# (3) In the else branch, Sorbet knows `x` must be nil
T.reveal_type(x) # => Revealed type: `NilClass`
default
end
end
In this example, we ask Sorbet (using T.reveal_type) what the type of x is at three places, and get different answers each time:
- Outside the
if, Sorbet only knows what thesigsaid aboutx. - In the
ifbranch, Sorbet knowsxis notnil. - In the
elsebranch, Sorbet knowsxmust benil.
Predicates
Sorbet bakes in knowledge of a bunch of Ruby constructs out of the box:
# typed: true
extend T::Sig
sig { params(x: Object).void }
def flow_sensitivity(x)
# (1) is_a?
if x.is_a?(Integer)
T.reveal_type(x) # => Integer
end
# (2) case expressions with Class#===
case x
when Symbol
T.reveal_type(x) # => Symbol
when String
T.reveal_type(x) # => String
end
# (3) comparison on Class objects (<)
if x.is_a?(Class) && x < Integer
T.reveal_type(x) # => T.class_of(Integer)
end
end
The complete list of constructs that affect Sorbet’s flow-sensitive typing:
ifexpressions /caseexpressionsis_a?/kind_of?/instance_of?(check if an object is an instance of a specific class. Note thatinstance_of?checks that the class matches exactly while the others consult the inheritance chain)nil?blank?/present?(these assume a Rails-compatible monkey patch on bothNilClassandObject)Module#===(this is howcaseon a class object works)Module#<,Module#<=(likeis_a?, but for class objects instead of instances of classes)- Negated conditions (including both
!andunless) - Truthiness (everything but
nilandfalseis truthy in Ruby) block_given?(internally, this is a special case of truthiness)
Warning: Sorbet’s analysis for these constructs hinges on them not being overridden! For example, Sorbet can behave unpredictably when overriding
is_a?in weird ways.
What about respond_to??
Sorbet cannot support flow sensitivity for respond_to? in the way that most people expect.
For example:
sig { params(x: Object).void }
def flow_sensitivity(x)
# Does not work:
if x.respond_to?(:foo)
T.reveal_type(x) # => Object
x.foo # Method `foo` does not exist
end
end
In this example, knowing that x responds to a method with the name foo does not tell Sorbet anything more specific about the type for x. Sorbet does not have any sort of duck-typed interfaces that let Sorbet update its knowledge of the type of x to "Object plus responds to foo", so it must keep the type of x at Object, which does not allow calling foo.
Note that even if Sorbet did support such a type, it’s likely that flow-sensitive type updates for respond_to? would still not be supported, because knowing that the method foo exists says nothing about what parameters that method expects, what their types are, or what the return type of that function is.
It’s possible that someday Sorbet could support a limited form of x.respond_to?(:foo) when one of the component types of x is a type which has a known method called foo. There is more information in this issue, which details the implementation complexity and limitations involved in supporting such a feature.
Code making the most of Sorbet is best written to avoid needing to use respond_to?. Some alternatives include:
- Use union types alongside one of the flow-sensitivity mechanisms that Sorbet already understands, like
is_a?orcase - Use interface types to require that a given interface method must exist.
If using respond_to? is absolutely necessary, use T.unsafe to call the method after checking for its existence:
sig { params(x: Object).void }
def flow_sensitivity(x)
if x.respond_to?(:foo)
# T.unsafe silences all errors from this call site
T.unsafe(x).foo
end
end
Flow-sensitivity and the Singleton module
Ruby has a module in the standard library called 'singleton', which can be used to create a class that has exactly one instance. Sorbet has special support for flow-sensitivity on classes that include Singleton and also are marked final!:
# typed: true
extend T::Sig
require 'singleton'
class Unset
include Singleton
extend T::Helpers
final!
end
sig { params(x: T.nilable(T.any(Unset, Integer))).void }
def example1(x: Unset.instance)
T.reveal_type(x) # => `T.nilable(T.any(Unset, Integer))
# `==` comparisons on Singleton types update the type in
# both the `if` and the `else` case:
if x == Unset.instance
T.reveal_type(x) # => `Unset`
else
T.reveal_type(x) # => `T.nilable(Integer)`
end
end
sig { params(x: T.nilable(Integer)).void }
def example2(x: nil)
T.reveal_type(x) # => `T.nilable(Integer)
if x == 0
# All `==` comparisons on non-Singleton types only update
# the type if the type test is true.
T.reveal_type(x) # => `Integer`
else
# When the `==` comparison above is false, the type of `x`
# remains identical to what it was outside the `if`.
T.reveal_type(x) # => `T.nilable(Integer)`
end
end
As mentioned in the example above, normally == only gives additional, flow-sensitive information about the type of a variable in the case that the type test was truthy.
But as we see within the example1 method above, using == on a Singleton value will allow Sorbet to update its knowledge about the type of x both when the == comparison is true and when it is false.
As seen shown in the example above, this technique can be useful to distinguish between cases when a possibly-nil, optional argument was explicitly passed at the call site and set to nil, or when a value was omitted at the call site and the default value of Unset.instance was used.
Note that using final! is required, as without it, the == comparison could return true in the presence of subclasses of Singleton classes.
→ Example of Singleton but not final!
Limitations of flow-sensitivity
An alternative title for this section: "Why does Sorbet think this is nil? I just checked that it’s not!"
Flow-sensitive type checking only works for local variables, not for values returned from method calls. Why? Sorbet can’t know that if a method is called twice in a row that it returns the same thing each time. Put another way, Sorbet never assumes that a method call is pure.
For example, consider that we have some method maybe_int which when called either returns an Integer or nil. This code doesn’t typecheck:
x = !maybe_int.nil? && (2 * maybe_int)
This problem is subtle because maybe_int looks like a variable when it’s actually a method! Things become more clear if we rewrite that last line like this:
# This is the same as above:
x = !maybe_int().nil? && (2 * maybe_int())
Sorbet can’t know that two calls to maybe_int return identical things because, in general, methods are not pure. The solution is to store the result of the method call in a temporary variable:
tmp = maybe_int
y = !tmp.nil? && (2 * tmp)
→ View full example on sorbet.run
Note: Many Ruby constructs that look like local variables are actually method calls without parens! Specifically, watch out for
attr_readerand zero-argument method definitions.
Note that given Sorbet’s architecture, it’s not possible to implement some sort of annotation that would mark a method as “pure” or “constant,” declaring that a method always returns the same value across consecutive calls to the method (this also precludes special support for methods defined via const or attr_reader). For more information on why, see this blog post which explains that control flow in Sorbet is a syntactic property, not a semantic property (which could be influenced by semantic annotations like this).
Tips
These are some helpful tips for helping Sorbet track the types of expressions throughout a program.
Prefer xs.compact to xs.reject(&:nil?)
Sorbet does not use the boolean predicate accepted by methods like Array#select, Array#filter, or Array#reject to infer a more narrow type about the element of the list.
To remove the nil values from an Array and have Sorbet understand that they are gone, use Array#compact instead:
sig { params(xs: T::Array[T.nilable(Integer)]).void }
def example(xs)
ys = xs.reject(&:nil?)
T.reveal_type(ys) # <- still nilable ❌
ys = xs.compact # <- non-nil ✅
end
Prefer xs.grep(A) to xs.filter { |x| x.is_a?(A) }
Sorbet does not use the boolean predicate accepted by methods like Array#select, Array#filter, or Array#reject to infer a more narrow type about the element of the list.
For the specific case of filtering a list using is_a?, prefer using Enumerable#grep:
sig { params(xs: T::Array[T.any(Integer, String)]).void }
def example(xs)
ys = xs.select { |x| x.is_a?(Integer) }
T.reveal_type(ys) # <- not just Integer ❌
ys = xs.grep(Integer)
T.reveal_type(ys) # <- only Integers ✅
end
Two gotchas:
- This requires the RBI definitions inside Sorbet 0.5.11388 or higher.
- This only works for classes, not modules.
If either of these gotchas apply, see the filter_map tip below.
Prefer xs.filter_map { ... } to xs.filter { ... }
Sorbet does not use the boolean predicate accepted by methods like Array#select, Array#filter, or Array#reject to infer a more narrow type about the element of the list.
For complicated flow-sensitive predicates (i.e., more than just checking for nil? or is_a?), use Array#filter_map. For example:
sig { params(xs: T::Array[T.any(Integer, String)]).void }
def example(xs)
ys = xs.select { |x| x.is_a?(Integer) && x.even? }
T.reveal_type(ys) # <- not just Integer ❌
ys = xs.filter_map { |x| x if x.is_a?(Integer) && x.even? }
T.reveal_type(ys) # <- only Integers ✅
end
Array#filter_map removes any element for which the filter returns a falsy value. If the value is truthy, the value at that index is mapped to the new, truthy value. The x if x.is_a?(Integer) evaluates to x (an Integer, thus always truthy) if it’s an Integer, or implicitly returns nil (which is falsy) if it’s a String.
We abbreviate “control flow-sensitive” to “flow-sensitive” throughout these docs, because Sorbet does little to no data flow analysis. (Data flow analysis is a separate family of techniques that models the way data flows between variables in a program.) ↩