id | title | sidebar_label |
---|---|---|
type-assertions |
Type Assertions |
T.let, T.cast, T.must, T.bind |
There are five ways to assert the types of expressions in Sorbet:
T.let(expr, Type)
T.cast(expr, Type)
T.must(expr)
/T.must_because(expr) {msg}
T.assert_type!(expr, Type)
T.bind(self, 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.
T.must_because
is like T.must
but also takes a reason why the value is not
expected to be nil
, which appears in the exception that is raised if passed a
nil
argument.
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
Here's the same example with T.must_because
, showing the user of custom
reasons. The reason is provided as a block that returns a String
, so that the
reason is only computed if the exception would be raised.
class A
extend T::Sig
sig {void}
def foo
y = T.must_because(nil) {'reason'}
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_because(x) {'reason'}
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
T.bind
works like T.cast
, except with special syntactic sugar for self
.
Like T.cast
, it is unchecked statically but checked at runtime. Unlike
T.cast
, it does not require assigning the result to a variable.
Sometimes we would like to use T.cast
to ascribe a type for self
. One option
is to assign the cast result to a variable, perhaps called this
:
this = T.cast(self, MyClass)
this.method_on_my_class
This is annoying:
- It requires replacing
self
withthis
everywhere it's used. - It prevents calling private methods.
If we tried to clean this up with something like self = T.cast(self, ...)
, the
Ruby VM rejects our code with a syntax error: self
is not a variable, and
can't be used as the name of one.
Thus, Sorbet provides T.bind
for this specific usecase instead:
T.bind(self, MyClass)
self.method_on_my_class
T.bind
is the only type assertion that does not require assigning the
assertion result into a variable, and it can only be used on self
.
T.bind
can be used anywhere self
is used (i.e., methods, blocks, lambdas,
etc.), though it is most usually useful within blocks. See
Blocks, Procs, and Lambda Types for more real-world usage examples.
At runtime, all of these assertions verify the expr
they are passed matches
the Type
they are passed.
Statically, e.g., when type checking with srb tc
, some of them are assumed
to hold, but not statically checked.
Assertion | Static | Runtime |
---|---|---|
T.let(expr, Type) |
checked | checked |
T.cast(expr, Type) |
assumed | checked |
T.must(expr) |
assumed | checked |
T.assert_type!(expr, Type) |
checked | checked |
T.bind(self, Type) |
assumed | checked |
When an assertion is assumed to hold statically, Sorbet will only use it for the purpose of updating its internal understanding of the types, and will never attempt to alert the programmer that an assumption might not hold. In this sense, those assertions can be considered Escape Hatches for getting something to typecheck that might not otherwise.
Note that even though all of these assertions are checked at runtime, some
individual types might never be checked at runtime, regardless of the type
assertion used. This includes the element types of generics (like the Integer
in T::Array[Integer]
), the argument and return types of
Proc Types, T.self_type
,
T.attached_class
, and others.
These assertions are also subject to the T::Configuration
hooks that
sorbet-runtime
provides for controlling runtime type checking. See
Runtime Configuration for more. By default, all of these
assertions will raise a TypeError
if they are violated at runtime.
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)
-
T.bind
is likeT.cast
, but only forself
,T.bind(self, String)
behaves like
self = T.cast(self, String)
if it were valid in Ruby to assign to
self
.