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:
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
{params(xs: String).returns(T.any(String, HeadError))}
sig 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.
do
sig :K, :V)
type_parameters(.params(
hash: T::Hash[T.type_parameter(:K), T.type_parameter(:V)],
key: T.type_parameter(:K)
).returns(T.any(T.type_parameter(:V), LookupError))
end
def self.lookup(hash, key); ...; en
# Convert a String to an integer, or return ParseError.
{params(source: String).returns(T.any(Integer, ParseError))}
sig 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
:message, String
prop end
class RanOutOfInput
include ParseError
end
end
Then at the caller side, it’s simple to handle the errors:
do
sig str: String)
params(.returns(T.any(Integer, HeadError, LookupError, ParseError))
end
def self.foo(str)
= head(str) # => c : T.any(String, HeadError)
c return c unless c.is_a?(String)
# => c : String
= lookup(STR_MAP, str)
r return r unless r.is_a?(String)
"#{c}#{r}")
parse(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:
- a successful result (
Integer
) - three kinds of failures (
HeadError
,LookupError
, andParseError
)
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:
do
sig str: String)
params(# does need to mention HeadError
.returns(T.any(Integer, LookupError, ParseError))
end
def self.bar(str)
= lookup(STR_MAP, str)
r 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.