Feature #17326
closedAdd Kernel#must! to the standard library
Description
Abstract¶
We should add a method Kernel#must!
(name TBD) which raises if self
is nil
and returns self
otherwise.
Background¶
Ruby 3 introduces type annotations for the standard library.
Type checkers consume these annotations, and report errors for type mismatches.
One of the most common and most valuable type errors is whether nil
is allowed as an argument or return value.
Sorbet's type system tracks this, and RBS files have syntax for annotating whether nil
is allowed or not.
Since Sorbet checks proper usage of nil
, it requires code that looks like this:
if thing.nil?
raise "The thing was nil"
end
thing.do_something
This is good because it forces the programmer to acknowledge that the thing might be nil
, and declare
that they'd rather raise an exception in that case than handle the nil
(of course, there are many other
times where nil
is both possible and valid, which is why Sorbet forces at least considering in all cases).
It is annoying and repetitive to have to write these if .nil?
checks everywhere to ignore the type error,
so Sorbet provides it as a library function, called T.must
:
T.must(thing).do_something
Sorbet knows that the call to T.must
raises if thing
is nil
.
To make this very concrete, here's a Sorbet playground where you can see this in action:
You can read more about T.must
in the Sorbet documentation.
Problem¶
While T.must
works, it is not ideal for a couple reasons:
-
It leads to a weird outward spiral of flow control, which disrupts method chains:
# ┌─────────────────┐ # │ ┌────┐ │ # ▼ ▼ │ │ T.must(T.must(task).mailing_params).fetch('template_context') # │ │ ▲ ▲ # │ └──────────┘ │ # └─────────────────────────────────┘
compare that control flow with this:
# ┌────┐┌────┐┌─────────────┐┌────┐ # │ ▼│ ▼│ ▼│ ▼ task.must!.mailing_params.must!.fetch('template_context')
-
It is not a method, so you can't
map
it over a list usingSymbol#to_proc
. Instead, you have to expand the block:array_of_integers = array_of_nilable_integers.map {|x| T.must(x) }
Compare that with this:
array_of_integers = array_of_nilable_integers.map(&:must!)
-
It is in a Sorbet-specific gem. We do not intend for Sorbet to be the only type checker.
It would be nice to have such a method in the Ruby standard library so that it can be shared by all type checkers. -
This method can make Ruby codebases that don't use type checkers more robust!
Kernel#must!
could be an easy way to assert invariants early.
Failing early makes it more likely that a test will fail, rather than gettingTypeError
's andNoMethodError
's in production.
This makes all Ruby code better, not just the Ruby code using types.
Proposal¶
We should extend the Ruby standard library with something like this::
module Kernel
def must!; self; end
end
class NilClass
def must!
raise TypeError.new("nil.must!")
end
end
These methods would get type annotations that look like this:
(using Sorbet's RBI syntax, because I don't know RBS well yet)
module Kernel
sig {returns(T.self_type)}
def must!; end
end
class NilClass
sig {returns(T.noreturn)}
def must!; end
end
What these annotations say:
-
In
Kernel#must!
, the return value isT.self_type
, or "whatever the type of the receiver was."
That means that0.must!
will have typeInteger
,"".must!
will have typeString
, etc. -
In
NilClass#must!
, there is an override ofKernel#must!
with return typeT.noreturn
.
This is a fancy type that says "this code either infinitely loops or raises an exception."
This is the name for Sorbet's bottom type, if you
are familiar with that terminology.
Here is a Sorbet example where you can see how these annotations behave:
Alternatives considered¶
There was some discussion of this feature at the Feb 2020 Ruby Types discussion:
Summarizing:
-
Sorbet team frequently recommends people to use
xs.fetch(0)
instead ofT.must(xs[0])
onArray
's andHash
's because it chains and reads better.
.fetch
not available on other classes. -
It's intentional that
T.must
requires as many characters as it does.
Making it slightly annoying to type encourages developers to refactor their code so thatnil
never occurs. -
There was a proposal to introduce new syntax like
thing.!!
. This is currently a syntax error.Rebuttal: There is burden to introducing new syntax. Tools like Rubocop, Sorbet, and syntax highlighting
plugins have to be updated. Also: it is hard to search for on Google (as a new Ruby developer). Also: it
is very short—having something slightly shorter makes people think about whether they want to type it out
instead of changing the code so thatnil
can't occur.
Another alternative would be to dismiss this as "not useful / common enough". I don't think that's true.
Here are some statistics from Stripe's Ruby monolith (~10 million lines of code):
methood | percentage of files mentioning method | number of occurrences of method |
---|---|---|
.nil? |
16.69% | 31340 |
T.must |
23.89% | 74742 |
From this, we see that
-
T.must
is in 1.43x more files than.nil?
-
T.must
occurs 2.38x more often than.nil?
Naming¶
I prefer must!
because it is what the method in Sorbet is already called.
I am open to naming suggestions. Please provide reasoning.
Discussion¶
In the above example, I used T.must
twice. An alternative way to have written that would have been using save navigation:
T.must(task&.mailing_params).fetch('template_context')
This works as well. The proposed .must!
method works just as well when chaining methods with safe navigation:
task&.mailing_params.must!.fetch('template_context')
However, there is still merit in using T.must
(or .must!
) twice—it calls out that the programmer
intended neither location to be nil
. In fact, if this method had been chained across multiple lines,
the backtrace would include line numbers saying specifically which .must!
failed:
task.must!
.mailing_params.must!
.fetch('template_context')