Typed throws

It is more an additive change than a substitute. But we already talked about that, it seems that it is not needed that overload

Sorry, but why wouldn't this compile? The compiler can see that the only possible error is a CatError, and besides, the second clause is a catchall, so why would there be an issue of "not being able to check exhaustiveness"?

That snippet won’t compile because in the last catch block, the type of error is Error but the Result’s Failure type is CatError. The last catch block is needed because the catch isn’t exhaustive if you just handle CatError, since callCatOrThrow() can throw any Error, so catch needs to handle either Error or any subtypes of Error and Error itself.

1 Like

It's not self-evident that this is a good thing... Why not () throws T -> Void let be a subtype of () throws -> Void only when T extends Error? Strictly more code will compile with that alternative.

do {
    throw Foo()
} catch {
    // error is Error but if we use inference from the single throw error is Foo.
}

So there you have the source compatibility issue why the magic catch that infers the type being thrown from the do clause.

IMHO this should get more attention in this proposal, because it solves issues with rethrows and has very likely other use cases as well.

I currently had a problem with rethrowing, where I basically wanted to make a function rethrows, but not based on a parameter. Something like that:

struct Foo {
    let maybeThrowing: () throws -> ()
    
    init(_ maybeThrowing: () throws -> ()) {
        self.maybeThrowing = maybeThrowing
    }

    func bar() rethrows {
        try maybeThrowing()
    }
}

let foo1 = Foo { print("Foo") }
foo1.bar()

let foo2 = Foo { throw Error() }

do {
    try foo2.bar()
} catch {
    print("Error")
}

This obviously does not compile, but if every function implicitly threw Never, we could rewrite it like that (Credits to this answer) :

struct Foo<E: Error> {
    let maybeThrowing: () throws E -> ()
    
    init(_ maybeThrowing: () throws E -> ()) {
        self.maybeThrowing = maybeThrowing
    }

    func bar() throws E {
        try maybeThrowing()
    }
}

var foo1 = Foo { print("Foo") } // Foo<Never>
foo1.bar()

let foo2 = Foo { throw Error() } // Foo<Error>

do {
    try foo2.bar()
} catch {
    print("Error")
}

foo1 = foo2 // error: Cannot assign value of Foo<Error> to variable of type Foo<Never>

Another feature that we would basically get for free, if we made every function throw Never, is this one:

2 Likes

This seems like a non-goal to me—at least as important as accepting as much code as possible, IMO, is ensuring that accepted code is easy to reason about and use. If typed throws can throw non-Error-conforming types, what is a user supposed to do with this:

func foo() throws Int { ... }
func bar() throws String { ... }

func doSomeStuff() /* ??? */ {
    try foo()
    try bar()
}
8 Likes

I think the idea in general seems promising, but none of the examples really stand out as being significantly improved by the feature. This one especially, the best you could really do is throw a general Error here, so the single improvement would be that you can make do with three catch clauses instead of for - no need for the catch-all.

And even this limited improvement with multi-error type cases only works "locally", callers of callFamily wouldn't have any benefits at all.

The more I read this pith the more it seems to me that typed throws only make sense when there is a simple and partly automatic way to construct and handle combined types, union types if you will, as we discussed:

If callFamily could throw something like KidError ⋃ SpouseError ⋃ CatError and this would be automatic, then it would make sense. If you need to catch and wrap each error type separately then it seems worse than untyped errors to me.

1 Like

I don't see how that is hard to reason about... I see only one reasonable answer, is that the code should't compile. You could catch the errors and store them in an enum and throw that. Or handle the errors right away. Or catch the errors and do something else.

For me this is too magical. Swift is always very explicit, about which values a generic can possibly represent. IMHO, what is proposed, feels the same as:

foo<T>(a: T, b: T) -> Bool {
    a > b
    // T is implicitly constrained to Comparable,
    // because it's used with a comparison operator
}

Which is basically C++-template-hell.

Yeah, the code as written above definitely should not compile, the point was just that it's a pretty big blow to composability if every usage of non-Error throwing functions requires the user to define an enum. We'd basically have a class of throwing functions which force client functions to handle all errors explicitly rather than propagating the errors to the caller.

I suppose you could create a generic enumeration to wrap an Error or Any error that could be reused across different functions, but then what have we really gained? I suspect it will be very surprising to beginners if:

func foo() throws Int { ... }
func bar() throws String { ... }

func doSomeStuff() throws {
    try foo()
    try bar()
}

does not compile, and even as a proficient Swift programmer I can foresee myself getting frustrated by this if non-Error throwing functions became a common pattern. Error is a super lightweight protocol, it's not difficult to provide a conformance for any given type.

4 Likes

It's actually grounded. This:

func foo<T>(_: [T: Int]) { ... }

Requires T to be hashable.

We might need it after all if rethrows does not propagate the error type.

1 Like

FWIW, I had the same reaction in the discussion thread until the [T: U] case that @Lantua was pointed out (which isn't specific to Dictionary—any constraints from the types specified in the signature will be implicitly inherited). The fact that the implicit constraints are limited to the signature of the function is restrictive enough to not upset me.

2 Likes

Oh okay...good to know. If it's allowed there, then it should be allowed here as well, I guess.
Still seems too magical to me, in the dictionary case as well. Nvm.

I'd like to see the implications of rethrows propagating the typed error with source compatibility or even with the scope of this proposal, maybe should be a follow up for this one, refactoring all throws seems more than enough for this proposal, don't you think?

Maybe @John_McCall can give some insight about this one. I'm certainly curious if it would be easier for the whole proposal to make this syntax analysis.

It's a nice, simple formalization of some of the type-system behavior, but it doesn't actually change the key question with rethrows, which is whether the signature of A below is equivalent to that of B or that of C:

// A
func foo(fn: () throws -> Void) rethrows

// B
func foo<erased E: Error>(fn: () throws E -> Void) rethrows E

// C
func foo<erased E: Error>(fn: () throws E -> Void) rethrows Error

B is the more aggressive answer, and it would generally allow callers of higher-order operations like && to maintain more precise error types. However, C is the current rule. If you assume rule B when calling a rethrows function written as A and compiled under rule C, the type system will be unsound, and if you enforce rule B instead of rule C when compiling a rethrows function written as A, you may reject code that was previously accepted.

You may also have noticed the erased that I added in the signatures above. Without it, neither of these interpretations is actually ABI-compatible with the current interpretation of A, because A does not actually take a concrete type argument E.

7 Likes

Should this change (B answer) be viable for the proposal? Seems like of we go for answer C many of the main sense of the proposal is gone. Also, would be source breaking? If the current code is not using a typed throw, it should behave as of today, it will change if you add the type in the throw, doesn't it?

It's nice when new features are "additive" that way, but no, rule B would place restrictions on rethrows functions regardless of whether typed throws is in use anywhere in the program.

Why is C the current rule? Does a function which rethrows allow you to throw your own type other than what fn could potentially throw? Is there a good use case for that or was it an oversight that was discovered too late?

Would it make sense to let the library author decide how to transform A if this whole feature lands. Basically some kind of an attribute which would either transform it into B or in C.

My point is that feels like a half step backwards if a certain rethrowing function is known to be of the B kind but would be defaulted to C. There is literally nothing gained from that from the users perspective.