Only return nil if given nil

Sometimes you might want a Sorbet method signature like, “Returns T.nilable only if the input is T.nilable. But if the input is non-nil, then so is the output.”

With clever usage of Sorbet generics, this is possible!

sig do
  type_parameters()
    .params(
      T.any(T.all(T.type_parameter(), NilClass), Amount)
    )
    .returns(T.any(T.all(T.type_parameter(), NilClass), String))
end
def get_currency(amount)
  if amount.nil?
    return amount
  else
    return amount.currency
  end
end
Full example on sorbet.run →

And then calling this method looks like this:

sig do
  params(
    Amount,
    T.nilable(Amount),
    NilClass
  )
  .void
end
def example(amount, maybe_amount, nil_class)
  res = get_currency(amount)
  T.reveal_type(res) # => String

  res = get_currency(maybe_amount)
  T.reveal_type(res) # => T.nilable(String)

  res = get_currency(nil_class)
  T.reveal_type(res) # => T.nilable(String)
end

Some things to note about how it works:

It’s using Sorbet’s support for generic methods.

In other languages, you might try to do something like this with overloaded signatures, but Sorbet doesn’t support overloads, except in limited circumstances.

It uses T.all (an intersection type) to approximate bounded method generics.

For the time being, Sorbet doesn’t have first class support for placing bounds on generic method type parameters, so we have to approximate them with intersection types.

The upper bound is NilClass, not T.nilable(Amount)!

This is the main trick. This lets us either return the thing we wanted (in our case, String), or the input if the input is nil.

T.all(T.type_parameter(), NilClass)

Returning something with a generic type means returning the exact input,This constraint about returning the input unchanged is an example of parametricity in action.

not merely “something with the same type as the input.”

So instead of having return nil on line 10, we have to write return amount.


When we call get_currency(amount), where amount is known to be non-nil, Sorbet is smart enough to know that get_currency returns String!

What’s happening here is that Sorbet is inferring the T.type_parameter(:U) to T.noreturn, which then collapses into String, because T.any(T.noreturn, String) is just String.



Update, 2023-10-02

I realized that the signature above gets a lot more clear if Sorbet were to support some sort of syntax for placing bounds on generic methods’ type parameters. For example using a hypothetical syntax:

sig do
  type_parameters(NilClass)
    .params(T.any(T.type_parameter(), Amount))
    .returns(T.any(T.type_parameter(), String))
end
def get_currency(amount)
  if amount.nil?
    return amount
  else
    return amount.currency
  end
end

This signature says that the T.type_parameter(:U) is upper bounded by NilClass (and not lower bounded, and therefore assumes the normal lower bound of T.noreturn).

Written like this, it becomes maybe more clear how this signature works: