Project

General

Profile

Actions

Feature #17326

open

Add Kernel#must! to the standard library

Added by jez (Jake Zimmerman) over 3 years ago. Updated almost 2 years ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:100852]

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:

→ View on sorbet.run

You can read more about T.must in the Sorbet documentation.

Problem

While T.must works, it is not ideal for a couple reasons:

  1. 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')
    
  2. It is not a method, so you can't map it over a list using Symbol#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!)
    
  3. 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.

  4. 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 getting TypeError's and NoMethodError'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 is T.self_type, or "whatever the type of the receiver was."
    That means that 0.must! will have type Integer, "".must! will have type String, etc.

  • In NilClass#must!, there is an override of Kernel#must! with return type T.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:

→ View on sorbet.run

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 of T.must(xs[0])
    on Array's and Hash'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 that nil 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 that nil 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')
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0Like0