Skip to content

Latest commit

 

History

History
486 lines (366 loc) · 14 KB

type-assertions.md

File metadata and controls

486 lines (366 loc) · 14 KB
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.

T.let

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
→ View on sorbet.run

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>'

T.cast

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

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:

  1. a use of T.must with a value that Sorbet is able to determine statically is nil, that raises an error indicating that the subsequent statements are unreachable;
  2. a use of T.must with a computed nil 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
→ View on sorbet.run

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
→ View on sorbet.run

T.assert_type!

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
→ View on sorbet.run

T.bind

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 with this 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.

Static vs Runtime Checking

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.

It's possible to opt out of runtime checking for individual calls to T.let, T.cast, and T.bind by adding checked: false, e.g. x = T.let(y, Foo, checked: false). This isn't recommended in most circumstances, even in performance-critical code; while adding checked(:never) to a method signature is an easy way to remove performance overhead, doing the same for T.let removes neither the method call overhead nor the overhead of constructing any type argument. For more effective options, see below.

Comparison of type assertions

Here are some other ways to think of the behavior of the individual type assertions:

  • T.let vs T.cast

    T.cast(expr, Type)

    is the same as

    T.let(T.unsafe(expr), Type)
  • T.unsafe in terms of T.let

    T.unsafe(expr)

    is the same as

    T.let(expr, T.untyped)
  • T.must is like T.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 like T.cast, but only for self,

    T.bind(self, String)

    behaves like

    self = T.cast(self, String)

    if it were valid in Ruby to assign to self.

Performance considerations

Unlike sig annotations, type assertions always have a performance cost, even if runtime checks are globally disabled or checked: false is used at individual callsites. T.let and friends are ordinary Ruby method calls, which have intrisic overhead, in addition to the overhead of constructing any type arguments.

This overhead isn't normally worth worrying about, but in code where you are already micro-optimizing to reduce method calls or object allocations, there are a few patterns that may be helpful:

Prefer method signatures over type assertions

It's often possible to avoid using a type assertion at all, without loss of type safety, with a slight refactoring.

For example, one common use for a type assertion is defining the type of an instance variable. One can frequently move this type definition to the signature of the constructor, taking advantage of Sorbet's ability to infer instance variable types when variables are set directly from constructor arguments.

Instead of:

sig {void.checked(:tests)}
def initialize
  @foo = T.let(MyObject.new, MyInterface)
end

Write:

sig {params(foo: MyInterface).void.checked(:tests)}
def initialize(foo: MyObject.new)
  @foo = foo
end

In other circumstances, breaking out a method can avoid a type assertion (which would itself involve at least one method call anyway).

For example, rather than:

def hot_method(..)
  # ...
  x = T.let(polymorphic_factory(foo_please), Foo)
  # ...
end

Write:

def hot_method(..)
  # ...
  x = make_foo
  # ...
end

sig {returns(FooType).checked(:tests)}
def make_foo
  polymorphic_factory(foo_please)
end

Avoid constructing type objects

The construction of an non-trivial type object is typically the most expensive part of a type assertion at runtime. One can usually mitigate this with the use of T.type_alias.

For example, rather than:

def hot_method(..)
  # ...
  foo = T.let({}, T::Hash[T.nilable(Symbol), T.any(Integer, Float)])
  # ...
end

Write:

FooHash = T.type_alias { T::Hash[T.nilable(Symbol), T.any(Integer, Float)] }

def hot_method(..)
  # ...
  foo = T.let({}, FooHash)
  # ...
end

Put type assertions behind memoization

Performance-sensitive methods are often memoized. In this case, it's usually possible to memoize the runtime type check as well.

For example, rather than:

sig {returns(Foo).checked(:tests)}
def foo
  @foo = T.let(@foo, T.nilable(Foo))
  @foo ||= something_expensive
end

Write:

sig {returns(Foo).checked(:tests)}
def foo
  @foo ||= T.let(something_expensive, T.nilable(Foo))
end

Note that for class methods, there's a better option:

@foo = T.let(nil, T.nilable(Foo))

sig {returns(Foo).checked(:tests)}
def self.foo
  @foo ||= something_expensive
end

Inline type assertions using flow-sensitivity

A type assertion can usually be replaced by an explicit ===, is_a? or equivalent check of a local variable, which will avoid a method call. Sometimes this makes code more verbose, but sometimes it can be a readability improvement instead, especially in cases involving T.must.

For example, in place of:

if foo.bar
  T.must(foo.bar).baz
end

Write:

x = foo.bar
if x
  x.baz
end