Typed Errors in Sorbet

I really like this post from Matt Parsons, The Trouble with Typed Errors. It’s written for an audience writing Haskell, but if you can grok Haskell syntax, it’s worth the read because the lessons apply broadly to most statically typed programming languages.

If you haven’t read it (or it’s been a while) the setup is basically: typing errors is hard, and nearly every solution is either brittle, clunky, verbose, or uses powerful type system features that we didn’t want to have to reach for.

Hidden towards the bottom of the post, we find:

In PureScript or OCaml, you can use open variant types to do this flawlessly. Haskell doesn’t have open variants, and the attempts to > mock them end up quite clumsy to use in practice.

What Matt calls “open variant types” I call ad hoc union types (see my previous post about checked exceptions and Sorbet). Naming aside, Sorbet has them! We don’t have to suffer from clunky error handling!

I thought it’d be interesting to show what Matt meant in this quote by translating his example to Sorbet.

I wrote a complete, working example, but rather than repeat the whole thing here, I’m just going to excerpt the good stuff. If you’re wondering how something is defined in full, check the full example:

→ View on sorbet.run

First, here’s how we’d type the three running helper methods from Matt’s post:

# Returns the first letter of the input,
# or returns `HeadError` if empty
sig {params(String).returns(T.any(String, HeadError))}
def self.head(xs); ...; end


# Gets the value for `key` in `hash`, or returns LookupError.
#
# This is normally defined in the stdlib, and in trying to
# match Matt's post, it ends up not being super idiomatic,
# but the types still work out.
sig do
  type_parameters(, )
    .params(
      T::Hash[T.type_parameter(), T.type_parameter()],
      T.type_parameter()
    )
    .returns(T.any(T.type_parameter(), LookupError))
end
def self.lookup(hash, key); ...; en


# Convert a String to an integer, or return ParseError.
sig {params(String).returns(T.any(Integer, ParseError))}
def self.parse(source); ...; end

Notice how in all three cases, we use a normal Sorbet union type in the return, like T.any(String, HeadError). All of the error types are just user-defined classes. For example, HeadError is just defined like this:

class HeadError; end

And ParseError is defined using sealed classes and typed structs to approximate algebraic data types in other typed languages:

module ParseError
  extend T::Helpers
  sealed!

  class UnexpectedChar < T::Struct
    include ParseError
    prop , String
  end

  class RanOutOfInput
    include ParseError
  end
end

Then at the caller side, it’s simple to handle the errors:

sig do
  params(String)
    .returns(T.any(Integer, HeadError, LookupError, ParseError))
end
def self.foo(str)
  c = head(str) # => c : T.any(String, HeadError)
  return c unless c.is_a?(String)
  # => c : String
  r = lookup(STR_MAP, str)
  return r unless r.is_a?(String)
  parse("#{c}#{r}")
end

The idea is that the return type includes the possible errors, so we have to handle them. This example handles the errors by checking for success and returning early with the error otherwise. This manifests in the return type of foo, which mentions four outcomes:

It would have worked equally well to handle and recover from any or all of the errors: Sorbet knows exactly which error is returned by which method, so there’s never a burden of handling more errors than are possible.

It’s fun that what makes this work is Sorbet’s natural flow-sensitive typing, not some special language feature. Notice how before and after the first early return, Sorbet updates its knowledge of the type of c (shown in the comments) because it knows how is_a? works.

Another example: if some other method only calls lookup and parse (but not head), it doesn’t have to mention HeadError in its return:

sig do
  params(String)
    # does need to mention HeadError
    .returns(T.any(Integer, LookupError, ParseError))
end
def self.bar(str)
  r = lookup(STR_MAP, str)
  return r unless r.is_a?(String)
  parse(r)
end

And while there’s never a need to predeclare one monolithic error type (like AllErrorsEver in Matt’s post), if it happens to be convenient, Sorbet still lets you, using type aliases. For example, maybe there are a bunch of methods that all return LookupError and ParseError. We can factor that out into a type alias:

MostCommonErrors = T.type_alias {T.any(LookupError, ParseError)}

That’s it! Sorbet’s union types in method returns provide a low-friction, high value way to model how methods can fail.