Some thoughts on typed `rethrows`

I've been playing with typed throws recently for a little library I've been experimenting on and have been butting against the current limitations of both typed throws and rethrows.

Looking at the discussions for the typed throws feature I'm having thoughts on what direction we could move rethrows towards that wouldn't paint us into a corner while making itself far more useful than it currently is. Let's run through them.

Current rethrows

As currently implemented, rethrows for the following function

func rethrowing<A, B>(a: () throws(A) -> Void, b: () throws(B) -> Void) rethrows

Means that if A != Never || B != Never the function throws(any Error), else it throws(Never)

Even if there is a single type parameter, the function will still throws(any Error), at least on the currently released compiler.

So there's two parts to what rethrows does:

  1. Determine whether a generic function throws
  2. What type of error it throws.

We're not going to discuss #2 and in fact I believe we should leave rethrows out of that. So all further discussion here omits what is being thrown.

Step 1: rethrows as a marker of when a function throws

Consider the following:

func doesTwoThings<A, B>(a: () throws(A) -> Void, b: () throws(B) -> Void) rethrows {
    do {
        try a()
    } catch {
        print("a threw an error. Whatevs")
    }

    try b()
}

This function will insist on declaring itself as throwing even if b does not throw

The obvious solution would be to be able to specify what types cause rethrows to kick in, as follows.

func doesTwoThings<A, B>(a: () throws(A) -> Void, b: () throws(B) -> Void) rethrows(B) {
    do {
        try a()
    } catch {
        print("a threw an error. Whatevs")
    }

    try b()
}

Step 2: rethrows + typed throws

So far so good. Let's take it further

struct DoesTheThing<Failure> {
    func doTheThing() throws(Failure) { ... }

    func mayThrow<A>(a: () throws(A) -> Void) throws(Failure) {
    do {
        try a()
    } catch {
        print("a threw an error. Whatevs")
    }

    try doTheThing()
}

So far so good. But it does have some unfortunate limitations since it forces us to throws(Failure) so

struct Wrapped<W: Error>: Error {
    let wrapped: W
}

struct DoesTheThing<Failure> {
    func doTheThing() throws(Failure) { ... }

    func mayThrow<A>(a: () throws(A) -> Void) throws(Failure) {
    do {
        try a()
    } catch {
        print("a threw an error. Whatevs")
    }

    do {
        try doTheThing()
    } catch {
        throw Wrapped(wrapped: error)
    }
}

Uh, compiler justifiably complains that we're trying to throw something other than Failure.

But if we declare mayThrow as throws(Wrapped<Failure>) then the compiler will thing it has to throw even when Failure == Never

Vanilla rethrows also won't do as the parameter has nothign to do with whether the function should throw or not.

Here we would want something like the following:

struct DoesTheThing<Failure> {
    func doTheThing() throws(Failure) { ... }

    func mayThrow<A>(a: () throws(A) -> Void) rethrows(Failure) throws(Wrapped<Failure>) {
    do {
        try a()
    } catch {
        print("a threw an error. Whatevs")
    }

    do {
        try doTheThing()
    } catch {
        throw Wrapped(wrapped: error)
    }
}

Assuming of course that we want mayThrow to have a typed throw. If rethrows doesn't match then the compiler can ignore the type of throws, and if we're going to throws(any Error) we can omit it as well.

Step 3: Basic multiple types

If there's more than one type involved in determining whether a function should throw or not, the basic idea would be to just add a list of types and if any of them is not Never then the function may throw.

struct DoesTheThing<Failure> {
    func mayThrow<A, B>(a: () throws(A) -> Void, b: () throws(B) -> Void) rethrows(Failure, B) {
        // We're going to skip the implementation now…
    }
}

This matches the current behavior for rethrows with more than one block parameter that may throw.

Step 4: Type Algebra

Let's not go there yet.

Enough Thoughts for Today

There's a few questions we'd want to answer before trying for a proper pitch, let alone an implementation:

  1. Would this disrupt existing code? Doesn't look like it. Non-typed rethrows can maintain its current behavior.
  2. Do all these rules make sense together? Please find any holes in my logic, I'm not the best person to see them if they're there.
  3. Does this look like something that can be banged into the compiler without too much (comparatively speaking) effort? I'm far from the most qualified person to answer this one.
  4. Does this approach paint us into a corner against future developments? We weren't doing much useful with rethrows especially now that typed throws exist. Separating typed rethrows from typed throws also lets us be more incremental.
2 Likes