Sorbet Does Not Have Checked Exceptions

Here’s a common question I get asked about Sorbet:

Does Sorbet support checked exceptions, like Java?

(In fact, this was the first question I was asked at my first conference talk.)

The answer: Sorbet doesn’t support checked exceptions, and I don’t think it ever should.

Before I dive in, there are two pretexts I’m assuming: either you asked this question and someone linked you this post, or the post title was enough to catch your attention. Either way, I’m going to take for granted that you know what I mean by “checked exceptions.” If you want a quick refresher, jump down to the Appendix and then come back.

My claim is that checked exceptions are a poor man’s ad hoc union types, that since Sorbet has ad hoc union types it doesn’t need checked exceptions. I’ll discuss this claim in three parts:

(If you want to skip straight to the good stuff, the analysis is down here.)

Background: Sorbet’s union types

The throws clause is the only point in the entire Java language that allows union types. You can tack “throws A,B,C” onto a method signature meaning it might throw A or B or C, but outside of the throws clause you cannot say “type A or B or C” in Java.

— James Iry, Checked Exceptions Might Have Their Place, But It Isn’t In Java (2012)

Sorbet supports union types. More specifically, Sorbet’s union types are ad hoc: any number of types can be unioned together on demand:

sig {returns(T.any(A, B, C))}
def foo; ...; end

By contrast, many languages with union types require predeclaring a union’s variants, for example in Rust:

enum AorBorC {
    A(A),
    B(B),
    C(C),
}

That Sorbet allows defining union types on demand is similar to Java’s throws clause, but more powerful: throws A, B, C is not a type, while T.any(A, B, C) is. We’ll see why that matters below.

Example: From checked exceptions to union types

Using Sorbet’s ad hoc union types, it’s mechanical to convert Java-style checked exceptions to Sorbet-annotated Ruby. To demonstrate:

Currency parseCurrency(String currencyStr) throws ParseException {
    Currency currency = KNOWN_CURRENCIES.get(currencyStr);
    if (currency == null) {
        throw new ParseException(
          "'" currencyStr + "' is not a valid currency", 0);
    }

    return currency;
}

This is a somewhat contrived Java method, but it’ll be good enough to demonstrate the concepts.

If parseCurrency is given a string it can’t handle, it raises a ParseException. It declares this with throws because ParseException is a checked exception. If the currency string is recognized, it returns some Currency object.

Here’s how we’d write that in Sorbet:

# (0) Ruby's standard library doesn't have `ParseException`,
# so I've re-implemented it.
class ParseError < T::Struct
  const , String
  const , Integer
end

# (1) return type + `throws` becomes just `returns`
# (2) Return type uses `T.any`
sig do
  params(String)
    .returns(T.any(Currency, ParseError))
end
def parse_currency(currency_str)
  currency = KNOWN_CURRENCIES[currency_str]
  if currency.nil?
    # (3) `throw` becomes `return`
    return ParseError.new(
      "'#{currency_str}' is not a valid currency",
      0
    )
  end

  currency
end

The important changes:

  1. Where Java had a return type and a throws clause, Sorbet just has a return type.
  2. Sorbet’s return type is a union type (T.any(...)). It mentions the Java method’s return type and all the exceptions mentioned in the throws.
  3. Where the Java example uses throw, the Ruby example uses return.

Our translation isn’t complete until we see how the parseCurrency caller side changes. In Java, we call parseCurrency like this:

Charge createCharge(int amount, String currencyStr) throws ParseException {
    Currency currency = parseCurrency(currencyStr);
    return new Charge(amount, currency);
}

With Sorbet, this snippet becomes:

sig do
  params(Integer, String)
    .returns(T.any(Charge, ParseError))
end
def create_charge(amount, currency_str)
  currency = parse_currency(currency_str)
  return currency unless currency.is_a?(Currency)

  Charge.new(amount, currency)
end

As before, the throws clause in Java becomes a union-typed return in Ruby.

The new bit is the explicit return ... unless .... Whereas uncaught exceptions implicitly bubble up to the caller, return values only bubble up if explicitly returned. This is a key benefit of the union types approach, which brings us to our next section.

Analysis: Why the union types approach is better

To recap, Sorbet’s union types are ad hoc, much in the same sense as the classes mentioned in Java’s throws clause. When converting from Java to Ruby, a single, union-typed return takes the place of a separate return type and throws clause.

First off, this translation preserves the best parts of checked exceptions:

But this approach is not only as good, it’s better, because:

And finally, let me get out ahead of some common counter arguments.

The union types approach requires more typing at the call site!

Yep. But I’ve already counted this as a blessing, not a curse.

But real-world Ruby code already uses exceptions!

Yep. But in Java too, the world is already split into checked and unchecked exceptions. In both Java and Ruby, exceptions are a fact of life, and you’ll always need a way to deal with unexpected exceptions (e.g., comprehensive tests, automated production alerting, etc.).

With checked exceptions, I could handle all the failures at once!

That’s true; with checked exceptions, it’s easy to write a single catch statement that handles all failures due to, say, a ParseException in a whole region of code, avoiding the need for code repetition.

The upshot is that with union types, we can just use functions. Take everything in the catch body, put it in a helper function, and call it at each call site. This cuts down on duplication, and I already mentioned how call-site granularity is a win.

That’s pretty much it. Sorbet doesn’t need checked exceptions, it already has ad hoc union types.


Appendix: Checked Exceptions

As a quick refresher, checked exceptions are a feature popularized by Java. The syntax looks like this:

void doThing() throws MyException {
    // ...
}

The throws keyword is a part of the method’s signature, just like argument and return types. It declares that this method might throw MyException.

Since it’s a part of this method’s signature the throws annotation will be checked at all call sites (just like argument and return types). A method containing calls to doThing must either catch all mentioned exceptions or repeat any maybe-thrown-but-uncaught exceptions in its own throws clause.

If a method throws multiple classes of exceptions, they can all be listed:

void doThing() throws MyException, YourException, AnotherException {
    // ...
}

The argument in favor of checked exceptions is that they’re explicit and machine-checked. Users don’t have to guess at what a method might throw, or hope that there’s accurate documentation—all benefits shared by static typing in general, which is a sympathetic goal.

Checked exceptions seem like a good feature on paper. In practice, they’re generally regretted. I’m nowhere near the first person to come to this conclusion, so instead I’ll link you to some previous discussions:

(The last one isn’t actually about checked exceptions: it’s just about exceptions and I like it, so I included it.)

Java has been copied and imitated for decades. Among all the features we see other languages copy from Java, checked exceptions are absent.