Sorbet – a type checker for Ruby – supports union types. A union type describes a value that can be one of several types. This post shows one scenario in which I found union types useful.
I occasionally use a Result Object when creating API wrappers. The Result Object can either contain the data from the API call or details of an error. You can model this the following way:
# typed: true
class ApiWrapper
extend T::Sig
class Result < T::Struct
const :data, T.nilable(T::Array[Integer])
const :success, T::Boolean
const :error_message, T.nilable(String)
end
sig {params(i: Integer).returns(Result)}
def list_all(i)
if i == -1
Result.new(success: false, error_message: "Invalid argument")
else
Result.new(success: true, data: [1, 2, 3, 4])
end
end
end
Our Result
object needs to have attributes that are only useful when the result is successful (i.e., Result#data
) and attributes that are only useful when the result is not successful (i.e., Result#error_message
). Those attributes had to be nilable to support both success and error scenarios.
If we use union types, we can improve this.
# typed: true
class ApiWrapperWithUnionType
extend T::Sig
# no more nilable types!
class Success < T::Struct
const :data, T::Array[Integer]
end
class Error < T::Struct
const :error_message, String
end
sig {params(i: Integer).returns(T.any(Success, Error))}
def list_all(i)
if i == -1
Error.new(error_message: "Invalid argument")
else
Success.new(data: [1, 2, 3, 4])
end
end
end
In the example above, we use a union type T.any(Success, Error)
to model all possible return types. The type of Success
or Error
indicates whether the result was successful or not. This means we don’t need to keep the boolean success
attribute, and we no longer need nilable types for error_message
and data
.
We can give the name Result
to this union type using a type alias. The type alias is convenient if you need to reference the union type elsewhere and want to avoid writing T.any(Success, Error)
in every place.
# typed: true
class ApiWrapperWithUnionType
extend T::Sig
class Success < T::Struct
const :data, T::Array[Integer]
end
class Error < T::Struct
const :error_message, String
end
# the type alias!
Result = T.type_alias { T.any(Success, Error) }
sig {params(i: Integer).returns(Result)}
def list_all(i)
if i == -1
Error.new(error_message: "Invalid argument")
else
Success.new(data: [1, 2, 3, 4])
end
end
end
The caller of the method that returns the union type will need to handle all the possible returned types. You can do this with a case statement.
# typed: true
class Caller
extend T::Sig
sig { void }
def perform
result = ApiWrapperWithUnionType.new.list_all(1)
case result
when ApiWrapperWithUnionType::Success
puts "Listing all elements: #{result.data.join(',')}"
when ApiWrapperWithUnionType::Error
puts "There was an error."
else
# guarantees statically and at runtime that the programmer
# has covered all cases
# https://sorbet.org/docs/exhaustiveness
T.absurd(result)
end
end
end
You can learn more about union types on Sorbet’s documentation.
I can send you my posts straight to your e-mail inbox if you sign up for my newsletter.