Status check: Typed throws

I think unions should be fine if the types are implicitly tagged as is the case for errors. I assume this would just be a special case for error types and not a generalized union type. I think as John McCall said, you might need to add Error to all of those unions as a fallback/default option. Swift already has that issue with raw value enums, so this wouldn't be any different than that.

Maybe implicit enum would be better terminology since a C-style union implies there is no runtime type information.

But also, if variadic generic enums allowed you to do something like this..

public enum Either<each Choice> {
  repeat case choice(each Choice)
}

// use it like this...
let either = Either<Int, Double, Float>.choice.0(5)

switch either {
  case .choice.0(let integer):
    print(integer)
  default:
    print("Not an integer")
}

And such an Either type were added to the standard library, maybe it would make sense to provide sugar for it using the OR type constraint?

Int | String would be sugar for Either<Int, String>
Int | String | Float would be sugar for Either<Int, String, Float>

Honestly if this kind of thing is not possible with variadic generic enumerations, I wonder why they were held back.

3 Likes

Consider these two examples:

  1. Out of line initialization of a variable to an opaque result of a function
func foo() -> some Sequence<Int> { ... }

let bar: ?
...
bar = foo()

There is no way to express type for bar, because function foo doesn't declare a nominal type to refer to. To allow this we need some kind of alias to the type of foo itself (to the function type) and a way to ask the return-type of it. For example:

let bar: foo.Type.ReturnType

Where the foo.Type expression refers to the function type () -> @_opaqueReturnTypeOf(foo), and .ReturnType refers to @_opaqueReturnTypeOf(foo).

  1. Generic constraint for a type to be a function-type
    Take a look at the signature of the withoutActuallyEscaping:
public func withoutActuallyEscaping<ClosureType, ResultType>(
  _ closure: ClosureType,
  do body: (_ escapingClosure: ClosureType) throws -> ResultType
) rethrows -> ResultType

This function only makes sense for function types, at least until we support escapability rules for non-function types. But there is no way to express a constraint for the ClosureType to enforce that.

To address these issues we could introduce a protocol all function-types to conform to:

protocol Function<SelfType, Arguments, ReturnType, Throws> {
  associatedtype ContextType // always opaque type describing a captured context, i.e. a tuple of all captured values
  associatedtype SelfType // type of self
  associatedtype Arguments // Parameter pack of args
  associatedtype ReturnType
  associatedtype Throws: Error | Never // indicates whether a function throws or not.

  func callAsFunction(self: SelfType, arguments: Arguments) -> Result<ReturnType, Throws>
}

This way we blur the distinction between function-types and nominal-types. Which in turn allows us to declare a constraint for the generic parameter ClosureType of withoutActuallyEscaping. It also allows us to explicitly propagate throwability of the closure to the function:

public func withoutActuallyEscaping<ClosureType, BodyType>(
  _ closure: ClosureType,
  do body: BodyType
) throws BodyType.Throws -> BodyType.ReturnType
where ClosureType: Function, BodyType: Function, BodyType.Arguments = (ClosureType)

(More thought needs to be given to the generalization of async functions)

PS: ContextType is required to express Sendable constraint

3 Likes

The expressive complexity of function types tends to grow over time as new features are added, and it will probably never make sense to have any generics feature that assumes it can generalize and destructure an arbitrary function type.

I never said "arbitrary". I specifically mentioned that my raw sketch of the protocol doesn't address async functions. It doesn't address arguments' modifiers as well (like inout, consuming, etc). The whole point of the constraint system is to narrow "A type" to something more concrete, but not exactly concrete.
So anyway, like I said before, type unions and function types are much broader topics than the particular feature "typed throws", and they will require many battles.

IIUC the decision on this does seem to be backing up progress on other work.

The particular one I have in my mind is the definition of Primary Associated Types for AsyncSequence. The original pitch document explicitly marks out a decision on typed throws as preventing further progress:

AsyncSequence and AsyncIteratorProtocol logically ought to have Element as their primary associated type. However, we have ongoing evolution discussions about adding a precise error type to these. If those discussions bear fruit, then the new Error associated type would need to also be marked primary. To prevent source compatibility complications, adding primary associated types to these two protocols is deferred to a future proposal.

If – as it would seem – adding typed throws seems likely, then perhaps adding a Failure associated type to AsyncSequence/AsyncIteratorProtocol would permit some forward progress. For example, it seems to me that this change would finally allow AsyncSequence to be useful as an opaque type by facilitating a route for the compiler to determine whether or not a rethrowing asynchronous sequence definitively throws or not from the value of its generic Failure parameter.

13 Likes

Thinking about this a little more, is there perhaps a staged solution that would move things forward without needing to commit to a full typed throws proposal?

One part that seems that it could stand as a proposal in its own right is permitting the the throws effects clause to include a thrown type. Even if, in the meantime, that thrown type is restricted to be either Never or Error.

Something equivalent to:

func f() throws(E) -> T // where E is Never or Error (until a full typed throws proposal...)

As I've seen discussed elsewhere, this would still allow the specification of functions without a specific type specified for E, they would simply be resolved to the fully qualified signature, so that:

func f() throws -> T // equivalent to `func f() throws(Error) -> T`

And:

func f() -> T  // equivalent to `func f() throws(Never) -> T`

With this in place a protocol like AsyncIteratorProtocol could be rewritten as:

@rethrows public protocol AsyncIteratorProtocol {
    associatedtype Element
    associatedtype Failure: Error 
    mutating func next() async throws(Failure) -> Self.Element?
}

It seems that this would be a non-breaking change, as a non-throwing asynchronous iterator's next() method would resolve to mutating func next() throws(Never) -> Element?, and a throwing asynchronous iterator's next() method would resolve to mutating func next() throws(Error) -> Element? – so the new Failure type could be inferred.

Then perhaps, if we could define a default parameter on a primary associated type, we could redefine AsyncSequence as something like:


@rethrows public protocol AsyncSequence<Element, Failure=Never> {

    associatedtype AsyncIterator: AsyncIteratorProtocol

    associatedtype Element where Element == AsyncIterator.Element
    associatedtype Failure where Failure == AsyncIterator.Failure
  
    func makeAsyncIterator() -> Self.AsyncIterator
}

Which would finally allow for using AsyncSequence as an opaque type:

func iterate(_ sequence: some AsyncSequence<T>) async
func iterateThrows(_ sequence: some AsyncSequence<T, Error>) async throws

That's a long way of saying, perhaps the function signature for typed throws stands on its own as a useful mechanism – even without a full typed throws proposal.

13 Likes

This is exactly what I wanted to propose today.
One thing I'd like to add is that it will require some additional logic to be implemented for for [try] await x in y. For context take a look at my workaround for "opaque AsyncSequence"

1 Like

Something I'd like to add is that I'm not sure if typed errors should be required to conform to Error. Perhaps Error should be a protocol specifically for error types that are intended to be used in the "untyped throws world". That would help prevent accidentally "leaking" error types that aren't meant for general usage, such as a hypothetical Either type, for which it'd be unreasonable to expect most error-handling code to handle correctly. It would also avoid artificially requiring error types to conform to Sendable in situations where it's known that the error value won't have to be passed around concurrency domains.

2 Likes

You can add an extended discussion around this topic here: Typed throws

2 Likes

Yes, I think it is. I've become interested in typed throws because it's a good direction for error handling in Embedded Swift, and have started on an implementation.

I'd be happy to work with other folks on the proposal and design. @minuscorp had a good start on a proposal that captures a lot of the motivation, and @John_McCall laid out much of the semantic model. Those would need to come together to have a complete design to make its way toward review.

Doug

46 Likes

That's fantastic to hear. I only regret that I didn't also include in my original post a status check on that unicorn I've long wanted, or on the whereabouts of my winning lottery ticket. :laughing:

Future directions

I think it wise that an initial implementation allow only a single error type to be specified. That addresses important specific use-cases (e.g. embedded) and can be the basis for real-world experience with which to inform future directions.

Still, I do want to think a little ahead on future directions, in order to ensure no doors are unwittingly closed. This might be a non-issue, in which case the following is not yet pertinent - I don't know nearly enough about the Swift compiler to even guess.

@John_McCall's post does a good job laying out the cons of supporting multiple error types, and why a degree of scepticism is healthy. Still, for the same reason precise error typing is warranted even though it might only be appropriate a minority of the time, I think being able to specify multiple errors types and values has its place too.

In a nutshell, I think it's very attractive if thrown errors function much like enums - the caller can see what the possibilities are, the compiler can verify completeness (all enum cases handled or a catch-all provided), etc.

But you can already make error enums, today, right?! Yes, but the problem is that composition is essential & frequent yet completely manual today. Most throwing functions call other throwing functions - often multiple with potentially different error types. So you either end up trying to shove every error condition into one giant enum (in the process, losing any indication of which actual error conditions a specific function can encounter) or you waste a lot of time translating between or making wrappers over error types. Either way your code becomes more laborious and error-prone to use & maintain, more verbose, and less flexible.

It'd be great if the compiler just did that for you, by implicitly creating those composite enums.

So ideally it would be possible to say:

  • throws without an explicit type, equivalent to throws(any Error).

    i.e. what we have today. So, boxed with intrinsic runtime overhead, but completely flexible and often the best choice if the caller's really only likely to log the error description anyway.

  • throws(someType) means only errors of that type.

    Has performance benefits at runtime (no boxing etc, at least no more than the type itself imposes such as if it is a reference type).

    A good option for fairly trivial or unlikely-to-change resilient APIs, especially since you can potentially still extend the error type itself to handle new cases (e.g. add additional enum values).

  • throws(someErrorType, ...) (or equivalently but IMO less ideally throws(someErrorType, any Error)).

    Technically means the type is any Error - with all the existing runtime overhead - but it provides a hint to the reader (human or otherwise, e.g. LSP) that someType errors can definitely be thrown and would be wise to prioritise in e.g. auto-completion of catch statements. Even while preserving the ability for the type(s) of thrown errors to be changed arbitrarily over time.

    A good option for resilient APIs with decidedly non-trivial functionality or implementations, where you don't want to limit yourself to even the vague class(es) of errors possible.

    Nominally this can be done today with structured documentation (/// throws: etc), and the LSP et al could use that equivalently for code completion etc. But structured documentation isn't actually syntax-checked let-alone type-checked, and is more likely to be missing or wrong (e.g. outdated).

  • throws(someErrorType, someOtherErrorType) means errors can be of either type but not any other type.

    This is essentially an implicit, anonymous enum of the specified types. It might have runtime benefits (no need for boxing if all the encompassed types are value types?) although still has to be interrogated at runtime to see which of the component types the error actually is. In any case, it does provide clarity to readers and it allows the compiler to check both:

    • That all the listed types are actually thrown in the function's implementation (whether directly or from children).

      It should at least warn, if not error, if a type is listed as thrown but never actually thrown (though some override might be necessary for this diagnostic, as there might be valid cases for this - e.g. a resilient API that no longer throws some types of errors but cannot remove the declaration because that would be backwards-incompatible?).

    • That the caller handles the complete set.

      This allows the caller to omit the "default" catch clause since the compiler knows the finite set of possibilities, a la an enum (although just like any enum, the caller is free to use a "default" catch clause anyway if it suits them better than explicitly enumerating all the cases).

      This then gets all the benefits we love from enums, such as if a new version of the function changes which errors it declares it can throw, the compiler can let all the callers know that they need to be updated and in precisely what way.

It gets even more powerful when you allow not just error types but values, e.g.:

  • throws(someErrorEnum.badInput, someOtherErrorEnum.networkError)

    Implicitly creates an anonymous, bespoke error enum that contains just those two cases, allowing named error enums to be better designed. e.g. logical categories like "NetworkError" and "JSONParserError" rather than a big fat catch-all "MyModuleError".

    This can be more efficient (than any Error) at runtime since it's technically equivalent to defining a new error enum and specifying that as the concrete, sole thrown type.

  • throws(someErrorEnum.badInput, NetworkError) (i.e. a mix of types and values)

    Likely a very common pattern where NetworkError encompasses what the lower-level functions can throw, and badInput is what the function in question can itself throw. Allows practical use of typed errors much further up the stack since the function can choose to apply abstraction for simplicity and forward-compatibility, e.g.:

    func search(query: String) throws(SearchError.emptyQuery,
                                      NetworkError) {
        guard !query.isEmpty else {
            throw SearchError.emptyQuery
        }
    
        try connection.performSearch(query: query)
    }
    
    // Elsewhere, in some Connection class:
    func performSearch(query: String) throws(NetworkError.timeout)
    

    Technically search(query:) only throws a subset of possible NetworkErrors - just the .timeout that performSearch(query:) throws - but it might be deemed (architecturally) that callers of the higher-level API don't care enough what specifically went wrong with the network, and/or that it's just wise for future-proofing to not make too many assumptions about the types of network errors that can crop up while performing a search, or that it's simply a better trade-off for code flexibility vs tightly coupling the declarations of the two methods (similar trade-off as for enums w.r.t. using a default switch case or listing every case explicitly).

    Nonetheless callers of search(query:) still know that the only possible errors are if the query is empty or some network error occurs. So they can tailor their responses much better than if it were any Error - e.g. catch SearchError.emptyQuery specifically and provide a crystal-clear bit of feedback to the user, and catch NetworkError more generically and just retry or somesuch.

1 Like

In such cases, you should use untyped throws.

and

are what I'd go for. Also, throws(Never) is equivalent to non-throwing.

From here on, I disagree with the direction you're going. A lot of the motivations and benefits for typed throws taper off once you try to get more specific than "throw this type", because you start creating implicit unions of types and possibly values, you lose the direct relationship with Result and throwing, and you end up getting the kind of junk-drawer-of-possibly-thrown-types that plague (e.g.) Java's checked exceptions. Typed throws with a single specified thrown type hits that happy middle.

Doug

20 Likes

Well, we'll see. :slightly_smiling_face:

1 Like

One suggested improvement I've heard for typed throws in languages like Java is

func foo() throws MyError {
  ...
}

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

That way, changing the throw type(s) of foo doesn't require changes everywhere.

1 Like

Composition of type union types and typed throws with exactly one type would be more scalable than trying to fit partial support for multiple types in typed throws. (IMO)

func f() throws(Error1 ∪ Error2)
func g() -> Result<Void, Error1 ∪ Error2>

I don't like like part of it. And in general I believe composition of two distinct features is better than some ad-hoc syntax. In particular what you're suggesting could be expressed as

func bar() throws(foo.Throws)

where foo.Throws is a type of throws of foo.
Designing it this way reduces the complexity of the Typed Throws feature and gives room for careful thought about how exactly throws' type inference from function reference should work.

I didn’t mean to suggest that exact syntax was how it had to be done, just that the concept should exist. I don’t want the capability to get lost in the syntax.

For sure. It's just that full-blown support for such union types is a much bigger undertaking. That and a bunch of other potential enhancements (e.g. conditional throws, rethrows of more than just immediate parameters, etc) are things I'd love to see too. Again, my emphasis in bringing up support for multiple types is just to ensure it's not inadvertently ruled out in the initial implementation. We won't know if it's worth it - or necessarily how best to implement it, e.g. full union type support vs something more specialised - until we get real-world experience with typed throws to begin with.

Something I'd like to add to this discussion is that a possible alternative to enums and union types is protocols, which would discourage people from trying to exhaustively handle each error case, and instead nudge them towards handling the errors using some common functionality that the errors have. This can be made more powerful with "blanket" (protocol-to-protocol) conformances like extension SomeErrorProtocol: MyErrorProtocol. For example, there can be a protocol for errors that can display a localized error message or graphical representation for a specific application, while existing types like CodingError or DistributedActorSystemError can be retroactively conformed to it as necessary.

I also wonder if it would be helpful to think of two "kinds" of error handling:

  1. General errors, which can potentially come from anywhere in the application. These are normally boxed in existentials and passed around in general-purpose error handling code. Callers should just let them propagate up the stack in most situations, since exhaustively handling each possible case would be impractical.
  2. Domain-specific errors, which warrant explicit handling of each possible case close to the call site. These don't necessary have to conform to Error, and not conforming to Error would prevent them from accidentally "escaping" into general-purpose error handling code if they would not be suitable for it.
    • Using non-Error-conforming enums for domain-specific errors would also prevent a certain class of bugs, where you accidentally handle errors that come from deeper in the call stack than you intended. This occasionally comes up in languages like Python, where exceptions like KeyError (from __getitem__), StopIteration (from __next__), and ValueError (when converting strings to other types like int or Decimal) usually signal a special condition to be handled by the caller, but catching them can potentially mask errors deeper in the call stack.

Building on one of @wadetregaskis's examples:

func search(query: String) throws SearchError {
    ...
}

// Does *not* conform to `Error`, since `.emptyQuery`
// should be handled as close to the call site as possible,
// and `.other` needs to be unwrapped from the enum before
// being propagated to general-purpose error handling code.
enum SearchError {
    case emptyQuery
    case other(any Error)
}

do {
    try search(query: "query")
} catch .emptyQuery {
    // domain-specific error handling...
} catch .other(let error) {
    throw error
}
2 Likes

I'll be glad on helping on whatever is needed for this great proposal!

1 Like