Here’s something everyone who’s new to Sorbet trips over:
The fix is to change found_it = false
to
found_it = T.let(false, T::Boolean)
.
But this is annoying and confusing, especially because basically no other type checker works this way. That makes it a good candidate to change so that Sorbet spends less time getting in people’s way.
And in fact, I have tried changing it! I want to talk about why this error exists at all, and my failed attempts at improving this.
Why does Sorbet have this weird error?
For performance and understandability.
As Nelson mentions in Why Sorbet is Fast, Sorbet’s inference algorithm is forward-only. This means that Sorbet inspects every piece of code in a method body at most once during inference, never revisiting a piece of code it’s already typechecked. Not only does this approach mean doing a linear amount of work, it means doing one unit of work per piece of code—the constant factors matter! It also means code at the bottom of a method never affects a type Sorbet inferred 100 lines earlier.
But “forward-only” is weird, because control flow graphs in
SorbetFor more idiosyncrasies of Sorbet’s CFGs, see Sorbet’s weird approach to exception
handling or Control Flow in
Sorbet is Syntactic.
can have cycles! It’s not possible to strictly sort code
in a Sorbet CFG from start to finish. Consider this program:
The CFG for this snippet looks something like this:If you really want to dive into Sorbet’s internals,
you can get it to print the exact CFG for a piece of code. See docs/internals.md:
CFG.
On line 1, Sorbet infers that x
is Integer
.
This means the call to takes_integer
on line 4 typechecks.
But then on line 5, Sorbet realizes that inferring a type of
Integer
for x
was a mistake: it should have
inferred T.nilable(Integer)
, but it’s too late–Sorbet isn’t
going to go back to line one and infer that x
has type
T.nilable(Integer)
. Sorbet has already missed an error it
should have reported: the call to takes_integer
(the second
time through this loop, the call to takes_integer
will
actually pass nil
instead of an Integer
). To
prevent this snippet from sneaking through without any errors,
it reports an error when it sees the incompatible assignment to
x
on line 5. Better late than never.
Importantly, Sorbet only needs this if the assignment happens inside a loop, because it’s the cycles in the CFG that cause problems. Also, Sorbet always assumes that blocks are loops, because Sorbet can’t know how many times a method will call a block. If the assignment which widens the type happens in non-cyclic code, there’s no type annotation needed.
TL;DR: Sorbet has this “incompatible assignment” error because without it, it would either miss reporting important type errors, or have to use a slower type inference algorithm with more action at a distance.
Okay,
but why not just assume that true
and false
are T::Boolean
?
This compromises the expressive power of Sorbet’s type system.
You’ll notice the original error mentioned TrueClass
and
FalseClass
:
Existing variable has type: `FalseClass`
Attempting to change type to: `TrueClass`
That exposes that Sorbet tracks in which environments a variable is
known to be exactly true
or false
, in addition
to when it’s T::Boolean
.
Maybe this is the problem? Maybe Sorbet should just assume that
x = true
results in x
having type
T::Boolean
, which would avoid the need for the
T.let
.
But that causes other problems. Sorbet is really good at modeling
situations where one variable being truthy implies some other
variable has a particular type.I’m told this is one of the particularly novel parts
of Sorbet’s inference algorithms—Dmitry, who built it, speaks
highly of it. It’s probably worth writing about more in the
future.
For example:
sig {params(bank_acct: T.nilable(String)).void}
def example(bank_acct)
check_balance = false
if bank_acct # now we know it's not nil
if is_special_account(bank_acct)
check_balance = true
end
end
if check_balance
T.reveal_type(bank_acct) # error: `String`
end
end
On line 12, Sorbet is smart enough to see that bank_acct
is not nil
, because we’ve we’re in an environment where
check_balance
is truthy, and we know that
check_balance
was only set to a truthy value in
environments where bank_acct
was not nil
.
Altogether, this allows the user to omit a redundant check to assert
that bank_acct
is not nil
directly on line
11.
If we changed Sorbet to treat check_balance = true
as
having type T::Boolean
instead of FalseClass
,
that would prevent Sorbet from modeling complex control-flow situations
like this.
I actually tried
building something that did that, and it caused many problems in
real world code that looked just like the code above. In the end, we
decided against landing the change, with the reasoning that the error
message for changing a variable in a loop is very clear, has an
autocorrect, and the resulting T.let
’d code is very
obvious.
By contrast, the error message if we did it the other way would have non-obvious error messages. There would have been workarounds to get code like the above to type check, but they would have contorted the code, making it substantially less obvious why the code was written like it was. And it would likely have been impossible to write autocorrects to introduce those workarounds, so people would have had to learn them by reading the docs, not via error messages.
So that’s why Sorbet is like this, and what we’ve tried to do to fix this in the past. This is still probably one of the most annoying parts of Sorbet, so it’s not unreasonable to hope it’ll get fixed one day. If you think you have a solution, feel free to let me know!