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 thesig
said aboutx
. - In the
if
branch, Sorbet knowsx
is notnil
. - In the
else
branch, Sorbet knowsx
must 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:
if
expressions /case
expressionsis_a?
/kind_of?
(check if an object is an instance of a specific class)nil?
blank?
/present?
(these assume a Rails-compatible monkey patch on bothNilClass
andObject
)Module#===
(this is howcase
on 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
nil
andfalse
is 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.
respond_to?
?
What about 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
Singleton
module
Flow-sensitivity and the 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_reader
and 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.
xs.compact
to xs.reject(&:nil?)
Prefer 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
xs.grep(A)
to xs.filter { |x| x.is_a?(A) }
Prefer 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.
xs.filter_map { ... }
to xs.filter { ... }
Prefer 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.) ↩