Status check: Typed throws

I've been digging through the archives on the topic of typed throws, and it's unclear to me where it's at. There's been an interesting story arc over the ~eight years it's been in sporadic discussion, where various factors have shifted as Swift has evolved, and some of the key opponents seem to have became [begruding] proponents, e.g. @John_McCall, yet the latest thread I can find on it just petered out without a conclusion (and it's not in Swift 5.9, at least).

I guess I'm mostly asking the Swift core team where they think it's at. It seems like it never actually got to the stage of being formally proposed (no SE-xxxx)? So it's never been officially approved or rejected… but what's the core team's temperature? Is it worth someone finally doing the real proposal?

For reference, the prior threads I've found, in reverse chronological order:

It also came up incidentally in another thread, [Pitch] Rethrowing protocol conformances, where @Philippe_Hausler seemed to say he'd actually developed a prototype implementation.

@ExFalsoQuodlibet also mentioned it a couple of months ago in the context of anonymous type unions (Multiple Types).

30 Likes

Note; that prototype was VERY limited scope and has likely bit-rotted a fair amount. The end analysis was that it would have some severe implications to type resolution that might be intractable with the approach that I took.

My conclusion from that brief prototype is that errors being a set of Never and any Error is the only real path. Where non-throwing functions are shorthand for throws(Never), throwing functions are shorthand for throws(any Error), and rethrows is a shorthand for throws(someParameter) where someParameter is a generic effect source of throwing.

Expanding to a concrete throwing type ends up creating a problem both with use and ABI. If a function is somehow limited to the errors thrown it means that is now part of the expected contract of that function; more cases or other failures cannot be introduced because that would break the expectations from the client code. Altering that would be tantamount to altering the signature therefore any change later on would end up breaking API usages. Furthermore it would also break ABI to change that. Since the signature must be a determined type for resolution - and that would mean that binaries don't catch the same thing.

Leaving the error as an existential means that it can always be caught or regrown. That leaves only one singular overload for throwing and therefore avoids the zoo of thrown errors by APIs (similar to how java has a myriad of exceptions types).

However I defer to folks who spend their time more at the compiler level since they have a better perspective on the implications.

4 Likes

I'd say that the Language Steering Group is open to considering it. It's a pretty cross-cutting feature to implement properly, though: we expect it will require fairly sophisticated changes to many layers of the compiler, from the type-checker all the way down through ABI lowering. So it's really down to who's capable of carrying that work out and how that should be prioritized vs other work they could be doing.

27 Likes

I'd like this feature to be supported, but IMO it heavily depends on

  1. Type unions.
  2. Incorporating function types to the domain of nominal types.

Both of them are much broader topics and need serious consideration.

1 Like

I'm not interested in rehashing the "if we should" of this idea right now, but the "if we can" is interesting since if it seems impractical or intractable, then that's obviously crucial to know as it would provide closure to this idea (not with the conclusion many folks would prefer, but still).

If I understand you correctly, you're saying that if the thrown error(s) are defined as a finite set then that imposes forward-compatibility problems? In the same sense that if your function is defined to return Int and you later want to make it Double, that'd be a breaking change?

Is that a show-stopper in some way, to supporting typed throws, or are you just pointing out the potential practical use problems, for modules that promise resilience? And I assume that these concerns don't apply for non-resilient modules?

Is this still the case even if the errors thrown are defined as any CustomErrorProtocol or SomeErrorEnum? I would have thought that in those cases it'd be just like generic return values or enums in general, w.r.t. resilience?

When you say 'resolution' do you mean symbol resolution (at link-time) or type resolution (at compile-time)?

Are you alluding to a possible implementation whereby the actual error type remains an existential (as it is now) despite the type metadata on error types? As opposed to actually 'specialising' the caller (in a sense) even if the exact type of the error is known at compile time.

Or are you just saying you think throws should remain untyped?

1 Like

You mean a general syntax to say "ProtocolA or ProtocolB", complimentary to the existing syntax for ProtocolA & ProtocolB?

I saw some overlapping discussions topics about this, during my research, but I didn't dig into them. Is there a succinct status for that idea - evidently it's not been formally proposed yet, unless I missed the SEP, but is there consensus within these forums on if and how that should be added?

I'm not familiar with this subject. Can you elaborate?

I'd love typed throws or even TypeScript-like union types for errors, but not very high on my wish list for Swift. It would be nice to constrain throws for better IDE support and documentation. It would also be nice so that you could do something like throw .myErrror. Instead of throw MyErrorEnum.myError.

For me this would all be of pretty limited utility since I generally handle errors that will likely need recovery more explicitly and try to conform any other errors to Foundation's LocalizedError protocol for presenting to the user. I treat all other errors the same basically.

1 Like

It is a commonly rejected change:

  • Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."

I suspect typed throws will eventually want some sort of union capability, but I would hope that it'd have the form of a "type function" — a type expression that evaluates to a type by some simple rule — rather than a general anonymous-sum feature. That is, you ought to be able to throw union(E, IOError), and that type will resolve to IOError if E is Never or IOError but fall back to Error if E is anything else. That fallback rule avoids the general pitfalls of these anonymous union types but also make it very special-case around errors.

10 Likes

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.

14 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

47 Likes