[Pitch] Unstructured task and typed errors

After a false start, @ktoso and I are back at it. We'd would like to make another attempt at adopting typed throws in the Task creation APIs and make it more difficult to ignore thrown errors accidentally. The spirit of the changes are identical to the previous proposal, but have been expanded to accommodate all the new Task APIs.

Just to recap:

The motivation for adopting typed throws can be clearly illustrated with a code example:

let task = Task {
  throw MyError.somethingBadHappened
}

do {
  _ = try await task.value
} catch {
  // type information has been lost and error is now `any Error`
}

Additionally, all the Task creation APIs are annotated with @discardableResult, including those that permit failure. This makes it extremely easy for the code creating the task to unintentionally ignore errors thrown in the body. This default has proven to be surprising and leads to accidentally missing thrown errors, as documented in this thread.

We'd love to hear your feedback!

Full proposal text is here: swift-evolution/proposals/0NNN-unstructured-task-error-handling.md at unstructured-task-errors Ā· mattmassicotte/swift-evolution Ā· GitHub

29 Likes

The typed throws adoption is pretty much a no brainer, however in this pitch thread it’d be good to revisit the removal of @discardableResult on error throwing functions.

This was discussed at length in various places, but now that we’re actually proposing it it’d be good to hear if anyone has concerns about it in this pitch (or later, in the proposal review).

Thanks everyone for your input,

7 Likes

+1, and I’d go further, and remove @discardableResult on any Task whose Success type isn’t Void.

15 Likes

Same question as before:

I guess this would still (silently) ignore the error?

func foo() {
    let task = Task {
      throw MyError.somethingBadHappened
    }
    print(task) // or task.cancel() after a small delay
}

I don’t think we’re in the business of ā€œyou took a task handle, but forgot to await on it, so you dropped the value or errorā€. It becomes even harder to diagnose if you escaped the handle for example…

In order to ā€œcannot forget to await a taskā€ you should be using structured concurrency. I see that your example the foo() is synchronous, but I don’t think there’s any reasonable diagnostics to invent here – how could the compiler tell, and what would you even do since you cannot await the task’s value. You’d have to handle the error inside the Task, so building diagnostics forcing you to do that would mean inventing some ways to effectively force you to await or never throw in a Task{} that you did not await on – that’s very undefined and hard to track in the general sense…

Yeah, you are probably right. I am thinking of some hypothetical infrastructure where errors are some kind of tag's "it" that you can't just ignore, or drop away, you could only pass it somewhere, if nothing better then into some terminal printItInBigRedBoldOnTheConsole(_ error: Error) API.

func bar() {
    let task = Task {
      throw MyError.somethingBadHappened
    }
    print(task) // or task.cancel() after a small delay
    // āŒ Error: Errors must be handled
    do {
        let x = try foo()
        print(x)
    } catch {
        // do nothing here
        // āŒ Error: Errors must be handled
    }
    let r = Result { try foo() }
    // do nothing with it
    // āŒ Error: Errors must be handled
}

Although this seems to be outside of this pitch scope.


+1 for the pitch.

+1 for me on dropping the @dicardableResultdicardableResult when Failure is not Never, I think it’s a better default. It might also be a good idea when Success is not Void.

Typed throw seems pretty straightforward, even if a bit theoretical to me because I haven’t use typed throws yet.

My concern with conditionalizing @discardableResult on the success type is in cases like this:

@discardableResult
func foo() async -> Int { ... }

Task { await foo() } // no longer discardableResult

Conditionalizing on the error type isn't as controversial because there is no equivalent problem there.

1 Like

It is so weird that Task.init and .detached just create things which don't require any more methods to start running. Doesn't feel like any other Swift API.

I think the best compromise* for the conflation of Task and Task.value that stems from that is to remove @discardableResult from everything except Task<Void, Never> overloads, but along with that, add this, to silence the warnings:

extension Task {
  func runDisregardingErrors() { }
}
Task { throw MyError.somethingBadHappened }.runDisregardingErrors()

is way more meaningful than

_ = Task { throw MyError.somethingBadHappened }

* Unless we can get new warnings, instead. Accurate warnings with the current discardable results would be better than the inaccurate Expression of type 'Task' is unused that will result from what's been proposed.

1 Like

I really miss not having exact typed exceptions and the ability to declare the type thrown exactly etc., that Java provides. It’s amazing how many thing Java got right and how so many people complained and/or ignored the power of typed exceptions. Here we are 30 years later and suddenly everyone is rediscovering what Java has provided for decades and wants similar functionality.

1 Like

Any thread of execution that throws an exception should print any unhandled exception out. Silent exception ignoring should not be possible. This is a code bug, and it should be visible to the developer if not actual user with a popup dialog/sheet/something for SwiftUI applications

1 Like

I'm very interested in this topic.

Regarding the proposal in this thread about adding typed throws support to Task, has typed throws support been considered for TaskLocal’s withValue(_:operation:file:line:) and withValue(_:operation:isolation:file:line:) as well?

Apologies if this is not the right thread to bring this up.

2 Likes

Originally we intended this to be just about the Task.init and friends, but we could consider doing the withValue one’s well here perhaps… they’re pretty related.

3 Likes

This is an existing problem today with @discardableResult and continuation functions:

elements.removeFirst()
// vs.
withAnimation { elements.removeFirst() }  // āš ļø

The solution of an explicit _ =:

withAnimation { _ = elements.removeFirst() }
// or
_ = withAnimation { elements.removeFirst() }

I think applies equally well here:

Task { _ = await foo() }
// or
_ = Task { await foo() }
2 Likes

While I disagree with this equality, because a Task is not its own value, I'm still for the change, because nothing good and true is available.

discardableResult being involved at all is a hack, attempting to cover up the missing ability to annotate a type as a wrapper around a result, delaying the applicability of where we currently use discardableResult:

@resultWrapper struct Task… {
  @result var value: Success…
  @result var result: Result…
  var notAResult: Success…
  @discardResult func discardResult() { }
  @discardableResult func notADiscard() { }
}
Task { "value or throw" } // Warning: "Result not handled" or something
await Task { "value or throw" }.notAResult // either the same warning or a related one.
Task { "value or throw" }.notADiscard() // either the same warning or a related one.
await Task { "value or throw" }.value // no warning
Task { "value or throw" }.discardResult() // no warning

I like this approach.. Perhaps we should do the same for anything, not just errors.

@ktoso and I have expanded the scope of the proposal to include these functions. Thank you for the suggestion!

4 Likes

@mattie @ktoso Thank you so much!!

1 Like