Union Types and Sorbet

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.