id | title |
---|---|
type-assertions |
Type Assertions |
There are four ways to assert the types of expressions in Sorbet:
T.let(expr, Type)
T.cast(expr, Type)
T.must(expr)
T.assert_type!(expr, Type)
There is also
T.unsafe
which is not a "type assertion" so much as an Escape Hatch.
A T.let
assertion is checked statically and at runtime. In the following
example, the definition of y
will raise an error when Sorbet is run, and also
when the program is run.
x = T.let(10, Integer)
T.reveal_type(x) # Revealed type: Integer
y = T.let(10, String) # error: Argument does not have asserted type String
At runtime, a TypeError
will be raised when the assignment to y
is
evaluated:
$ ruby test.rb
<...>/lib/types/private/casts.rb:15:in `cast': T.let: Expected type String, got type Integer with value 10 (TypeError)
Caller: test.rb:8
from <...>/lib/types/_types.rb:138:in `let'
from test.rb:8:in `<main>'
Sometimes we the programmer are aware of an invariant in the code that isn't currently expressible in the Sorbet type system:
extend T::Sig
class A; def foo; end; end
class B; def bar; end; end
sig {params(label: String, a_or_b: T.any(A, B)).void}
def foo(label, a_or_b)
case label
when 'a'
a_or_b.foo
when 'b'
a_or_b.bar
end
end
In this case, we know (through careful test cases / confidence in our production
monitoring) that every time this method is called with label = 'a'
, a_or_b
is an instance of A
, and same for 'b'
/ B
.
Ideally we'd refactor the code to express this invariant in the types. To reiterate: the preferred solution is to refactor this code. The time spent adjusting this code now will make it easier and safer to refactor the code in the future. Even still, we don't always have the time right now, so let's see how we can work around the issue.
We can use T.cast
to explicitly tell our invariant to Sorbet:
case label
when 'a'
T.cast(a_or_b, A).foo
when 'b'
T.cast(a_or_b, B).bar
end
Sorbet cannot statically guarantee that a T.cast
-enforced invariant will
succeed in every case, but it will check the invariant dynamically on every
invocation.
T.cast
is better than T.unsafe
, because it means that something like
T.cast(a_or_b, A).bad_method
will still be caught as a missing method statically.
T.must
is for asserting that a value of a nilable type is
not nil
. T.must
is similar to T.cast
in that it will not necessarily
trigger an error when srb tc
is run, but can trigger an error during runtime.
The following example illustrates two cases:
- a use of
T.must
with a value that Sorbet is able to determine statically isnil
, that raises an error indicating that the subsequent statements are unreachable; - a use of
T.must
with a computednil
value that Sorbet is not able to detect statically, which raises an error at runtime.
class A
extend T::Sig
sig {void}
def foo
x = T.let(nil, T.nilable(String))
y = T.must(nil)
puts y # error: This code is unreachable
end
sig {void}
def bar
vals = T.let([], T::Array[Integer])
x = vals.find {|a| a > 0}
T.reveal_type(x) # Revealed type: T.nilable(Integer)
y = T.must(x)
puts y # no static error
end
end
T.assert_type!
is similar to T.let
: it is checked statically and at
runtime. It has the additional restriction that it will always fail
statically if given something that's T.untyped
. For example:
class A
extend T::Sig
sig {params(x: T.untyped).void}
def foo(x)
T.assert_type!(x, String) # error here
end
end
Here are some other ways to think of the behavior of the individual type assertions:
-
T.let
vsT.cast
T.cast(expr, Type)
is the same as
T.let(T.unsafe(expr), Type)
-
T.unsafe
in terms ofT.let
T.unsafe(expr)
is the same as
T.let(expr, T.untyped)
-
T.must
is likeT.cast
, but without having to know the result type:T.cast(nil_or_string, String)
is the same as
T.must(nil_or_string)