Blocks, Procs and Lambda Types
T.proc.params(arg0: Arg0Type, arg1: Arg1Type, ...).returns(ReturnType)
This is the type of a Proc
(such as a block passed to a method as a &blk
parameter) that accepts arguments of types Arg0Type
, Arg1Type
, etc., and
returns ReturnType
.
At present, all parameters are assumed to be required positional
parameters—T.proc
types cannot declare optional nor keyword parameters.
Types of procs are not checked at all at runtime (whereas methods are), and
serve only as hints to srb
statically (and for documentation).
Here’s a larger example:
# typed: true
# (1) Declare the type of a block arg with `T.proc`:
sig {params(blk: T.proc.params(arg0: Integer).void).void}
def foo(&blk)
x = T.let('not an int', T.untyped)
# (2) The `T.proc` annotation is not checked at runtime.
# (This won't raise even though x is not an Integer)
yield x
end
# (3) Sorbet incorporates the `T.proc` annotation into what it knows statically
foo do |x|
T.reveal_type(x) # Revealed type: Integer
end
Optional blocks
Use T.nilable
to declare that a method can take an optional block:
# typed: true
extend T::Sig
# (1) Declare optional block with `T.nilable(...)`:
sig {params(blk: T.nilable(T.proc.void)).void}
def foo(&blk)
T.reveal_type(blk) # Revealed type: `T.nilable(T.proc.void)`
# (2) Use `block_given?` to check whether a method was given a block:
if block_given?
T.reveal_type(blk) # Revealed type: `T.proc.void`
end
# (3) Equivalently:
if blk
T.reveal_type(blk) # Revealed type: `T.proc.void`
end
end
Because we used block_given?
above, Sorbet will know that outside the if
expression blk
might be nil
, while inside the if
it’s not nil
.
yield
Annotating methods that use Ruby’s yield
keyword yields control to the provided block even when a method
declaration doesn’t mention a block parameter:
def foo
yield
end
This is valid Ruby, and Sorbet will accept this code too. Implicitly, Sorbet
will know that the method might accept a block, but it will treat the block
itself as T.untyped
. In order to give a signature to this method, the block
parameter will need a name:
def foo(&blk) # <-
yield
end
And once it has a name, the method can be given a sig:
sig {params(blk: T.proc.returns(Integer)).returns(Integer)}
def foo(&blk) # <-
yield
end
Note that the yield
itself in the method body doesn’t need to change at all.
Since every Ruby method can only accept one block, both Ruby and Sorbet are able
to connect the yield
call to the blk
parameter automatically.
Methods that do not take blocks
It’s hard for Sorbet to know whether a method takes a block or not. Technically,
Ruby allows passing a block at runtime to all methods, regardless of whether
that method declares an explicit &blk
parameter or uses the yield
keyword.
This means that Sorbet can only catch “Method does not take a block” errors when the method definition:
- has a signature
- does not mention a
&blk
parameter - is defined in a
# typed: strict
file
Absent all three conditions, Sorbet allows passing a block to a method that might not actually accept a block.
# typed: strict
files?
Why To ease the adoption in pre-existing Ruby codebases, Sorbet allows methods to
use yield
but not declare a &blk
parameter in # typed: true
files. We may
reconsider this decision in the future, but for now, Sorbet can only be sure
that a method does not take a block if the method is defined in a
# typed: strict
file.
Note that this applies to RBI files too: if a method is defined with a
signature, that does not mention a &blk
parameter, in a # typed: strict
RBI
file, Sorbet will error for attempts to pass a block to the method.
For RBI authors, it’s important to ensure that # typed: strict
RBIs correctly
declare whether a method takes a block or not, or else use # typed: true
for
that RBI file to opt the methods defined in that RBI out of “Method does not
take a block” errors.
Prefer blocks to procs or lambdas
Sorbet’s type inference gives substantial preference to Ruby blocks
(f { ... }
) over procs and lambdas (f(proc { ... })
/ f(-> { ... })
). Code
making heavy use of procs and lambdas will make it hard to avoid depending on
T.untyped
accidentally.
Here are some examples:
sig { params(blk: T.proc.params(x: Integer).void).void }
def takes_block(&blk); end
sig { params(f: T.proc.params(x: Integer).void).void }
def takes_lambda(f); end
takes_block do |x|
T.reveal_type(x) # => Integer ✅
end
takes_lambda(-> (x) do
T.reveal_type(x) # => T.untyped ‼️
end)
f = -> (x) { x }
T.reveal_type(f) # => T.proc.params(arg0: T.untyped).returns(T.untyped) ‼️
takes_lambda(f)
For lambdas and procs, sorbet only does return-type inference (not argument type inference).
For blocks, Sorbet doesn’t have to do type inference: it simply reads the type of the block from the associated method.
By contrast, Sorbet computes a type for all non-block arguments (including procs and lambdas) before type checking a call to a method. That means that even when written like this:
takes_lambda(-> (x) { x })
Sorbet computes a type for the first positional argument f
as if the user had
written this:
f = -> (x) { x }
takes_lambda(f)
It attempts to compute a type for f
without “looking ahead” at the type of
takes_lambda
. There’s more on this implementation decision in
Why does Sorbet sometimes need type annotations for local variables?,
but it comes down to a combination of performance and simplicity.
What can I do for better proc and lambda types?
Use blocks instead, if possible.
Make a typed wrapper that takes a block and returns a proc or lambda:
MyProcType = T.type_alias { T.proc.params(x: Integer).returns(Integer) } sig { params(blk: MyProcType).returns(MyProcType) } def make_typed_proc(&blk) blk end f = make_typed_proc { |x| x }
This is good because the body of the proc will benefit from well-typed arguments, and the proc type only has to be written once—there is little runtime overhead for this pattern.
Use a
T.let
annotation.f = T.let( -> (x) { x }, T.proc.params(x: Integer).returns(Integer) )
Using
T.let
with->
instructs Sorbet to assume that theT.proc
annotation holds when typechecking the body of the lambda. Specifically, Sorbet assumes that the lambda arguments have the types specified in theT.let
annotation and checks the lambda body to ensure it returns the specified return type.This only works with Ruby’s
->
lambda syntax, withKernel.lambda
, and withKernel.proc
(it doesn’t work when calling thelambda
andproc
methods on Kernel implicitly, without a receiver).f = T.let(-> () { 0 }, T.proc.returns(Integer)) ✅ f = T.let(Kernel.lambda { 0 }, T.proc.returns(Integer)) ✅ f = T.let(Kernel.proc { 0 }, T.proc.returns(Integer)) ✅ f = T.let(lambda { 0 }, T.proc.returns(Integer)) ❌ f = T.let(proc { 0 }, T.proc.returns(Integer)) ❌
For lambdas, use
-> () { ... }
orKernel.lambda { ... }
syntax instead oflambda { ... }
syntax.This will ensure that Sorbet treats
return
inside the lambda as returning from the lambda, not from the enclosing method (which is one main difference that sets lambdas apart from procs/blocks in Ruby).
T.proc.bind
Annotating the self type with Many Ruby constructs accept a block argument in one context, but then execute it in a different context entirely. This means that the methods that exist inside the block are not the methods that exist outside the block (like is usually the case).
As an example, this is how Sorbet’s sig
pattern works:
# (1) `params` doesn't exist outside the `sig` block:
params(x: Integer).void # error: Method `params` does not exist
# (2) But `params` does exist inside the `sig` block:
sig do
params(x: Integer).void # ok!
end
This also happens a lot with certain Rails APIs, etc. Sorbet has direct support
for this sort of pattern using an extra argument on T.proc
called .bind
:
# (0) We're simplifying how `sig` specifically works a bit here,
# but the general ideas apply.
module T::Sig
# (1) Use `T.proc.bind` to annotate the context in which the block will run
sig {params(blk: T.proc.bind(T::Private::Methods::DeclBuilder).void).void}
def sig(&blk); end
end
# This comes from a private, internal sorbet-runtime API that implements sigs:
module T::Private::Method::DeclBuilder
def params(params); end
def returns(type); end
def void; end
end
Here’s another example that’s a little more contrived but which shows both the method call site and method definition site:
# typed: true
extend T::Sig
# (1) Use `.bind(String)` to say "will be run in the context of a String":
sig {params(blk: T.proc.bind(String).returns(String)).returns(String)}
def foo(&blk)
"hello".instance_eval(&blk)
end
upcased = foo do
# (2) Sorbet knows that `self.upcase` is available because of the `.bind`
self.upcase
end
puts(upcased) # => "HELLO"
T.bind
Casting the self type with As mentioned above, by default, Sorbet assumes that a block executes in a
context where self
has the same type as the lexically surrounding scope.
The T.proc.bind
annotation (from the previous section) can change this
assumption, but is only valid for use on the distinguished &blk
parameter of a
method:
- It cannot be used with non-
&blk
parameters at a call site. - It cannot be used with procs or lambdas that are assigned into a variable, disconnected from any single call site.
(This is due to some simplifying architectural choices in the implementation of Sorbet’s type checking algorithm.)
We still might want to ascribe a type to self
for non-&blk
usages. For
example, look at the block passed to before_create
below:
class Post
before_create :set_pending, if: -> {
draft? # error: Method `draft?` does not exist on `T.class_of(Post)`
}
def draft?
true
end
end
By default, Sorbet assumes that when draft?
runs, self
will have type
T.class_of(Post)
. In reality, before_create
will execute the lambda provided
to the if
argument in a context where the block’s self
has type Post
.
To type this code accurately, Sorbet requires a
T.bind
annotation in the lambda:
class Post
before_create :set_pending, if: -> {
T.bind(self, Post)
draft? # OK!
}
# ...
end
Like with T.cast
, the full range of Sorbet types can be used in the T.bind
annotation. For example, here is a more complicated example that uses T.any
with T.bind
:
For more information on T.bind
, see
Type Assertions.
Proc.new vs proc
In Ruby there’s several ways to create a Proc: proc
and Proc.new
. Sorbet
handles them differently because of how parameter arity works internally.
Basically, you can use T.proc
as the type for a proc
(or lambda {}
or
-> {}
), but if you create your Proc using Proc.new
, then the type is a
Proc
.
The main downside of the Proc.new
approach is that you can’t set argument
types on it.
Here’s an example:
sig {returns(T.proc.params(a1: Integer).returns(Integer))}
def lowercase_proc
# if you are using `proc`, use `T.proc` in your sig
# this is the preferred approach
proc {|n| n * 2 }
end
sig {returns(Proc)}
def proc_dot_new
# if you are using `Proc.new`, use `Proc` in your sig
# note that you won't be able to define arguments/return types in your sig
# avoid this approach if possible
Proc.new {|n| n * 2 }
end
In general, you’re better off avoiding Proc.new
if you can. There’s a
rubocop rule
you can use to enforce this.