[Pitch N+1] Typed Throws

@Douglas_Gregor

This is only my hand-wavy take as I worked on the implementation of the parsing before. I certainly haven’t tested if that’s actually possible, so one of the authors of the pitch can maybe say more.

2 Likes

Agreed. This will unlock so much unrealised potential for ‘AsyncSequence’. Beyond the primary associated type benefits, no longer needing to define both throwing and non throwing source sequence variants, plus the ability to finally define a generic ‘AsyncSource’ type will really make it a much more expressive and versatile type.

6 Likes

While the rationale for softening the stance that untyped throws are all we need is not complete in this poposal, I agree with the reasons given by @John_McCall in Precise Error Typing. While it's unfortunate that the parenthesis are required to make the grammar unambiguous, I'd rather deal with some extra parentheses than an ambiguous grammar.

Overall +1.

2 Likes

not sure if this was discussed somewhere before, but what about:

func foo() throws<FooError>
// instead of 
func foo() throws(FooError)

at least at first glance it feels more type-related and less function-y to me...

1 Like

< > was always more indicative of a generic context, () otherwise just wraps content in it.

Also, issues with parsing with no parenthesis have been noted before by @Zollerboy1

2 Likes

I don't have a strong opinion either way here about throws X vs. throws(X), but there are plenty of places in the parser involving contextual keywords where disambiguation occurs based on whether the tokens are separated by a line break. For example,

let a = [any P]()  // allowed
let a = [any
  P]()  // not allowed

If the version without parentheses is preferred, I think it could be implemented such that an error type following throws cannot be separated from it by a newline:

func f() throws mutating  // mutating treated as a terribly named error type
func g()

func f() throws
mutating func g()  // mutating treated as a modifier on g()
17 Likes

Very excited to see the section about AsyncSequence here. This truly unlocks a lot of unrealised potential for AsyncSequence when it comes to API design.

I have some questions on the proposal:

  1. Does this supersede the underspecialized @rethrows?
  2. Can a protocol have multiple associated types where different methods use different types, e.g.
public protocol Foo<Failure1, Failure2> {
  associatedtype Failure1: Error = any Error
  associatedtype Failure2: Error = any Error
  func bar1() throws(Failure1)
  func bar2() throws(Failure2)
}
  1. We should include a section around withChecked[Throwing]Continuation and withUnsafe[Throwing]Continuation. Those are essential building blocks for most async algorithms and the place where we currently cannot nicely generalise between throwing and non-throwing algorithms.
  2. The one issue that this opens up with AsyncSequence is that currently a lot of algorithms and types such as AsyncStream are throwing CancellationErrors on next() in the throwing variants. If the Failure type becomes generic we cannot throw this error anymore. This isn't necessarily a blocker but it means users of AsyncSequences with a typed throw cannot differentiate anymore if the sequence terminated due to cancellation or due to finishing (same situation as we have with non-throwing AsyncSequences right now)
9 Likes

Here, the [] marks the beginning and the end of the expression, not the any being in place, I think.

You can see the implementation here. When any is reached in an expression context, the isContextualExpressionModifier function checks whether the next token is at the start of a line to determine whether the keyword is a modifier or not. It's independent of whether it's inside an array or not.

A clearer example of the same logic would be this:

let x = consume f()  // consume is a modifier

let x = consume      // consume is an identifier
  f()                // f() is a separate statement
3 Likes

I'd like to second this as well.

As for the ambiguity concern raised by @Zollerboy1, I think it'd be a shame bringing in a whole lot of code noise for a relatively rare edge case.

3 Likes

Maybe we could do something like the following?

public struct AsyncThrowingStream<Element, Failure: Error> {
    public enum StreamError: Error {
        case taskCancelled
        case other(Failure)
    }

    // ...
}

extension AsyncThrowingStream: AsyncSequence {
    public struct Iterator: AsyncIteratorProtocol {
        public mutating func next() async throws(StreamError) -> Element? {
            // ...
        }
    }
}

In discussions I've had with the community about typed throws, I recall two branching motivations:

  • wanting to specify the full set of errors that can be thrown
  • wanting to specify a minimum set of capabilities for the errors that can be thrown

The first is well explained the proposal and handled by using concrete types, but for the latter, a more natural fit might be to declare a protocol refining Error, and throwing its existential type:

protocol ErrorWithBacktrace {
  var backtrace: Backtrace { get }
}

// I don't want to promise what kinds of errors I'll throw, but I
// promise they'll carry a backtrace
func doStuff() throws(any ErrorWithBacktrace) {}

I would expect support for this to fall out from what's proposed, though the proposal doesn't explicitly state whether it's allowed.

If it is supported, that raises a question of how to join different error types that already have a subtyping relationship. Do we still fall all the way back to any Error, or do we take advantage of the subtype relationship if there is one?

struct SpecificErrorWithBacktrace: ErrorWithBacktrace {
  var backtrace: Backtrace
}

do {
  if x {
    throw SpecificErrorWithBacktrace()
  } else {
    try doStuff() // throws any ErrorWithBacktrace 
  }
} catch {
  // is `error` still `any Error` here, or `any ErrorWithBacktrace`?
}

As for whether the syntax is throws(T), throws<T>, or throws T, although I have to say that I personally find the parens a bit visually noisy, we should also consider potential other future parameterized effects that functions may have and the effect that would have on the syntax. For instance, if we introduce generators, we might use the syntax (T, U, V) yields(Element) -> Void or something like that to indicate the type of the generator. New effect keywords like yields will likely have to be contextual keywords for compatibility purposes, further increasing the potential for ambiguity if we compose them like (T, U, V) throws yields(Element) -> Void, so having parentheses (or some kind of brackets) around the parameters seems like good future-proofing. (This is not to say we're going to do generators imminently or anything like that, it's just an example of another function effect that would have obvious use for a type parameter like throws.)

24 Likes

It would be nice to make new keywords reserved rather than contextual in new language modes.

1 Like

All else being equal I prefer new keywords being contextual, depending on how common of a word we're using and whether it's something one would reasonably want to use as an identifier.

4 Likes

If we really want to talk about the bright sparkling future, it's fun to think about supporting user-defined effects and handlers too, which wouldn't be keywords at all.

3 Likes

Even in a new language mode, we need to be considerate about whether the benefit of reserving a keyword and thus requiring anyone who used that name to change their code is higher than adding some new rules in the parser or adding a little bit of "noise" to the syntax to make it unambiguous.

Given the large and ever increasing number of contextual keywords in Swift, I'm not sure squatting on all of them even in a new language mode would be worth the breakage it would cause.

(And this is coming from someone whose life would be a lot easier if keywords were always reserved, because I'm constantly having to add special cases in swift-format to make sure we don't split tokens in a way that causes them to be parsed incorrectly.)

4 Likes

If the guidance is:

  • if you throw a single error type, you can use typed throws
  • but if you throw multiple error types, you have to throw any Error

How does that related to Embedded Swift, where you can't use the any Error existential?

How will authors in embedded environments without any Error handle methods that need to call multiple different throwing methods with different error types?

// If we can't throw `any Error` in Embedded Swift, what do we do here?
func throwsMultipleErrors() throws(???) {
  try methodA() // throws ErrorA
  try methodB() // throws ErrorB
}
2 Likes

In essence, when there are multiple possible thrown error types, we immediately resolve to the untyped equivalent of any Error .

How does this work when targeting a runtime without existential types like Embedded Swift, which was cited as a major motivation for this pitch? (Ha, I see @cal beat me to this question :wink:)

I find myself in begrudging agreement that throws should be as expressive as Result, though I would be inclined to argue that parameterizing Result on its Failure type was a mistake. :wink:

Regardless, I still strongly believe that resilient functions should not incorporate their error types into their ABIs. If the pitched feature were implemented as-is, that would mean the compiler should reject typed throws when compiling in resilient mode.

I think there’s an obvious alternative that at least warrants mention in the pitch: enhancing Error with the functionality that has most strongly motivated the request for types throws, that being the ability to exhaustively switch over known error domains:

// Adds a `code` property to Error.
// The type of `code` is determined by an associated `Domain` type.
// For source compatibility, Error defaults to a Domain with no codes.
protocol Error {
  associatedtype Domain: ErrorDomain = DefaultErrorDomain
  var domainName: String
  var code: Domain.Code
}

protocol ErrorDomain {
  associatedtype Code
  static var name: String
}

struct DefaultErrorDomain: ErrorDomain {
  typealias Code = Void
}

extension Error where Domain.Code == Void {
  var code: Void { () }
}

extension Error {
  var domainName: String { Self.Domain.name }
}

This preserves the ability to use Error as the error currency type for resilient libraries while still allowing the caller to test the Domain and then possibly switch over the code:

do {
  try openFile()
} catch {
  switch(error.domainName) {
  case NSPOSIXErrorDomain:
    // blah
  case NSCocoaErrorDomain:
    // blah
  }
}

In a restricted environment like EmbeddedSwift, these types might not be parameterized in order to force a simpler error ABI:

protocol Error {
  #if EMBEDDED_SWIFT
    typealias Domain = ErrorDomain
  #else
    associatedtype Domain: ErrorDomain = DefaultErrorDomain
  #endif

  var domainName: String
  var code: Domain.Code
}

#if EMBEDDED_SWIFT
  struct ErrorDomain {
    typealias Code = Int
    static var name: String
}
#else
  protocol ErrorDomain {
    associatedtype Code
    static var name: String
  }

  struct DefaultErrorDomain: ErrorDomain {
    typealias Code = Void
  }

  extension Error where Domain.Code == Void {
    var code: Void { () }
  }

  extension Error {
    var domainName: String { Self.Domain.name }
  }
#endif
1 Like

If we don't want it to infect their ABI only, there's an option of accepting typed throws but using the same ABI as throwing Error, which would also have the benefit (?) of allowing existing library-evolution-enabled APIs to narrow their throws type without breaking their ABI.

3 Likes

That would still be a source-breaking change for clients, which the compiler should prevent resilient libraries from doing.