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
.