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!
And then calling this method looks like this:
sig do
params(
amount: Amount,
maybe_amount: T.nilable(Amount),
nil_class: 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
.
.all(T.type_parameter(:U), NilClass) T
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(U: NilClass)
.params(amount: T.any(T.type_parameter(:U), Amount))
.returns(T.any(T.type_parameter(:U), 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:
- If the caller passes in a possibly
nil
value, Sorbet infersT.type_parameter(:U)
to its upper bound. - Otherwise, Sorbet is free to leave the inferred type at the lower
bound of
T.noreturn
, andT.any(T.noreturn, Amount)
is simply the same asAmount
.