[Pitch N+1] Typed Throws

Strong +1 for this feature. It'll be very nice to use internally inside modules, and I could even see some use cases for public APIs

1 Like

Overall the updated version looks really great!

init(catching body: () throws -> Success) where Failure == any Error { ... }
Replace this with an initializer that uses typed throws:
init(catching body: () throws(Failure) -> Success)

If we add such an initializer isn't this a potential source breaking change since the new type of the Result is going to have a typed throw and it might be stored in a Result<Foo, any Error>. This store might not happen on the same line but further down in the program and just rely on the currently inferred type. Same applies to the Task and [Checked|Unsafe]Continuation APIs.

The with(Checked|Unsafe)ThrowingContinuation operations should incorporate typed throws, e.g.,:

This feels a bit weird because one can suddenly create a non-throwing continuation from the with(Checked|Unsafe)ThrowingContinuation APIs. Personally I would prefer extending the with(Checked|Unsafe)Continuation and drop all of the throwing in API naming. This looks like it is possible since by just introducing a new method with a generic parameter. Relatedly, we might be able to mark the throwing variants as deprecated.

Task groups
Similar to the continuation APIs the proposal should mention what happens with task groups. They also benefit from gaining a typed throw which is the error thrown from the body closure. Additionally, it might make sense to introduce an API where one can restrict the error thrown from the child tasks similar to how one can specify the childTaskResultType and group.next() & group.waitForAll() would now also be constrained to error type.
I haven't fully explored what implications typed throws have on all the task group APIs but we should revisit all of them. One thing to check here is if group.next() or group.waitForAll() are currently throwing a CancellationError or not.

Async[Throwing]Stream
Similar story here, we should discuss what implications typed throws has on AsyncStream and if we could adopt typed throws on it. Probably only AsyncThrowingStream can adopt typed throws here. @Douglas_Gregor is it possible to make the next() method from AsyncThrowingStream throw the generic Failure parameter without breaking ABI?

4 Likes

Getting rid of all the noise around throwing vs non-throwing types and withNnn companions would be a huge step! If I think about concurrency-land (continuations, AsyncSequence implementations, TaskGroups, ...) - being able to deprecate out all throwing versions and have it all automagically work with typed throws would be oh so very wonderful!

man, my excitement is at 110% percent - please make this a reality, it's too good not to have it now ; )

1 Like

Regarding this example: Wouldn't this close the door for potential future directions where we would like to have exhaustive catching of all potential error types?

I don't think so - it could always be refined further in future, such as if sum types are added to the language, such that the implicit error variable is e.g. CatError | KidError (or whatever the symbolic notation might end up being). I think that would be source-compatible since any Error is a superset of any possible sum type (in this context), so anything you were already doing in the catch clause would still work…?

Conceptually, I've thought of a typed throws as being useful when you want the ergonomics and language features today given by untyped throws in code which otherwise might just return Result<T,E>

1 Like

Perhaps I wasn't clear. I wasn't saying typed throws as pitched here would unequivocally have solved this (although, maybe - perhaps this API would have been using throws(DecodingError) which would have precluded an ABI-compatible-but-functionally-incompatible change of throwing a JSONError).

I only said it's an example where stronger typing for errors would have helped.

In this case, to be completely safe it would have had to enumerate the specific error values that could be thrown, thus the problem would be clearly revealed both on the library side (function no longer throws one of its declared errors) and on the caller side (function adds a new error case, which is either ABI-breaking and would have been handled some other way (e.g. a new version of the function) or would have already been required to be handled by callers through a default catch clause (which, in this specific example, may or may not have avoided the issue entirely depending on the details, but which at least makes it more resilient generally)).

i.e. it's the age-old enum problem, just in an error context. All the normal safe-guards that Swift has around enums could be applied here to errors and would have made it much harder for a breaking change like this in an OS update.

1 Like

I am torn on this feature.

On one hand it makes language more precise.
On another hand it makes language more complicated and in many cases this feature will not be used.

Will this feature be more useful because of the cases when it is needed than a nuisance when it is not needed?

1 Like

The initializer itself isn't a source-breaking change, because for every existing function is either throwing or not---it doesn't have typed throws. Once you start adopting typed throws in an API, that typed-throw information will ripple through these APIs. And that's a good thing: it means we don't break any existing code, but once you start adopting typed throws you get

To be fair, the change to make Task.sleep and Task.checkCancellation use typed throws are source-breaking. Maybe we should reconsider those changes, or pull them out into a separate document.

There are specific callouts for "Swift 6" in the proposal that show places where we have to hold back on inferring typed throws to maintain source compatibility.

It might be... I should probably pull the concurrency API updates into a separate proposal, because there are going to be a lot of them.

Yes, although it might involve a little trickery.

I think it will be mostly invisible, and only become a nuisance if someone decides to use it where it shouldn't be used, and gets themself stuck.

Doug

6 Likes

Also, just thinking about this point more, if you take that attitude then why have error types at all? Why not just have throw with no argument, and only } catch { with no qualifiers? Or at best some completely opaque type that's only CustomDebugStringConvertible or somesuch so you can log it but do nothing much else?

I'm a bit confused, now, about what your interest in typed throws is (as a co-author of the proposal!) if you don't actually want the thrown values used…?

(I'm not trying to be rude or a smart-arse or anything, I'm just genuinely a bit thrown by your comment.)

Or am I just jumping to the wrong conclusion when you say "the perils…", as in, do you mean not that it should never be done but that you have to be judicious and/or extra defensive or somesuch?

I didn't say "don't do it", I said one has to be careful, and I was most specifically not wanting folks to conclude that typed throws would have saved this headache and should therefore be used everywhere.

There's a case to be made that the library itself should have stuck with the same error type and added a new case instance, because the language has affordances for dealing with new cases in existing libraries. That's independent of typed throws, and my criteria for when to use typed throws still imply that one should not use typed throws for this API.

Doug

1 Like

Got it, thanks for clarifying.

i really, really wish Error had been designed with logging/reflection in mind, i can’t count how many times i’ve screamed internally because i logged a generic Error (no description available) because a dynamic cast or type wrapper somewhere broke the reflection and the more detailed information about the error is simply unprintable even though the error instance might actually contain a lot of detailed information like:

extension MattelClient
{
    struct Error:Swift.Error
    {
        static
        var namespace:String { "Mattel Error" }

        let code:Int
        let message:String
    }
}

and yet it just prints itself as Error!

1 Like

I wrote a longer reply about how typed throws would've helped here but ultimately I think you're mostly right in this case. The only thing I can think of that typed-throws would've helped here is that it would've forced the authors to more deeply consider how the JSON errors would fit into the overall DecodingError rather than simply being able to return their own error type. Whether that would've lead to a better experience or not is unclear, but it may behoove the community to come up with additional guideline around public error types. This applies both to typed-throws and public error types in general so it's probably valuable in general.

1 Like

Right, and it's important to not forget the error source sitting in front of the keyboard, in all this. No matter how strict the typing is for errors, humans can still make it break. But there is value in making it hard to do not just outright bad things but also accidental not-great things. I see value here in this proposal for that, like the aforementioned case where simply forcing the person mucking with the JSON decoder to think a little harder about what they were throwing might have avoided this issue (or at least made them update the documentation!).

1 Like

In which ways is that source-breaking? I can only see this very uncommon use-case, but probably there's more examples:

func foo(_: UInt64) throws {}

var bar = Task.sleep(nanoseconds:)
bar = foo // Works before making Task.sleep use typed throws but errors afterwards

I think that makes sense. The concurrency APIs are very broad and would explode this proposal in complexity.

1 Like

I tend to agree a separate proposal might make sense, for those reasons. But it might be worth still leaving a note in this one about the extent to which this proposal helps (or doesn't) async code (e.g. the seeming limitations relating to CancellationError). For folks to keep in mind when considering this language enhancement overall. I'm strongly in favour of it, but I don't want anyone to think the proposal tries to shove suboptimal bits under the rug.

Some of the links in the table of contents don't work — if the section has code in its title, then the link's URL has the wrong fragment. For example, the link to the section "Typed rethrows" tries to take me to #typed--rethrows-, but the correct fragment is #typed-rethrows.

@Douglas_Gregor can you fix this?


It seems that this proposal could help with testing functions that call fatalError, precondition, and similar functions, which is actively being discussed on these forums. Consider this function:

func requiresAPositiveNumber(_ number: Int) {
    precondition(number > 0)
    // the rest of the function goes here
}

Currently, there's no good way to test that this function disallows numbers that aren't positive, since calling it with a nonpositive number would crash the test suite. Even if you were to isolate the test to a separate thread, the crashed thread could still leak resources and leave locks in a bad state. However, with this proposal, we could do this:

protocol ValidationStrategy {
    associatedtype Failure: Error
    static func validationFailure() throws(Failure) -> Never
}

enum PreconditionValidationStrategy: ValidationStrategy {
    static func validationFailure() -> Never {
        preconditionFailure()
    }
}

enum ThrowingValidationStrategy: ValidationStrategy {
    static func validationFailure() throws(ValidationError) -> Never {
        throw ValidationError()
    }
}
struct ValidationError: Error {}

func requiresAPositiveNumber<V: ValidationStrategy>(
    _ number: Int,
    validationStrategy: V.Type = PreconditionValidationStrategy.self
) throws(V.Failure) {
    guard number > 0 else {
        V.validationFailure()
    }
    // the rest of the function goes here
}

Production code would call requiresAPositiveNumber with PreconditionValidationStrategy and the function would act the same as the original function (including its non-throwing signature), but test code could call it with ThrowingValidationStrategy and verify that the function correctly rejects non-positive numbers.

2 Likes

I think my particular mistake shows arguments both ways.

  • If the library signature was “throws DecodingError” that would have forced the library authors to “translate” their new error condition into that type. (They might have added a new enum case to do that, or reused an existing one.) I think that’s likely to get them a better API, with more understandable error feedback (e.g. the description string they chose looks uncomfortable in DecodingError context, which is a good thing as it’s actively misleading). Score +1 for typed throws!
  • But, I was trying to do something that you really shouldn’t do. (Relying on implementation details of which more-specific-error-cases are sent in which error conditions, which are not documented as a library contract and not a straightforward consequence of the semantics of the errors.) I got bitten by it, and rightfully so. If the library contract was “throws DecodingError” it would actually encourage me to make this mistake, because it gives me a false sense of security. Score -1 for using typed throws in this particular way.

(Even switching on the error enum would not have saved me, because it’s perfectly legitimate for the library authors to change the implementation details of which enum case particular situations fall under. Those error cases are designed to provide rich debugging feedback, not to distinguish semantic classes of problems for flow control.)

I definitely see more clearly the risks of applying typed throws where they’re not appropriate, and of spending a lot of time focusing on the attractive nuisance of fine-grained error semantics. I was already doing that, but it’s pretty clear that I shouldn’t have been: that’s less straightforwardly the wrong move if the language gives us better tools to do it with.

1 Like