[Pitch N+1] Typed Throws

Thank you @Alex_Martini, that's really helpful!

The discussion on the patch is particularly insightful. Excerpt:

@jrose: If we ever add typed errors to the language, then we'd want to make sure the only thing thrown is the error that was caught. Even now it might be a nice restriction that's still in the spirit of rethrows : the function will never throw its own errors.

@Lily_Ballard: I actually disagree about the typed errors; the catch block would have to only throw errors that are part of the declared set of types, but I see no reason why it can't return a different error, such as one that wraps the error thrown by the parameter.

Furthermore, I don't think we can reasonably define "the function will never throw its own errors" without being needlessly restrictive. For example, my own code that motivated this change looks like

func scoped<T>(key: String, @noescape f: () throws -> T) rethrows -> T {
   do {
       return try f()
   } catch let error as JsonError {
       throw error.withPrefix(key)
   }
}

(where JsonError.withPrefix() returns a new error that has the given key prefixed onto the access path)

This function is not throwing its own error, it's merely modifying the error that was thrown, but I don't see how to define "throw its own errors" in any meaningful fashion that doesn't prevent this usage.

@jrose: Well, it depends on whether "rethrows" means "only throws if the closure throws" or "only throws what the closure throws". That seems like an important distinction that may be worth discussion.

So yeah, all things old new again. :laughing:

It goes on - and I strongly encourage everyone here to go read the full thread - but there's one more bit that I want to highlight:

@jrose: I don't know. If the rethrows code is in a library, it seems weird to get an error out that I didn't throw. If I'm deliberately throwing an error that contains information about my crash, I'd be very upset if I couldn't catch it. Any client of a rethrowing function that actually wants to catch the error now needs to know how to get it out of the one that's actually thrown.

I didn't see any of us talk about this yet in this current thread, but it's a really good point that @jrose made way back then.

Typed throws can help a little if we let them - e.g. if we allow rethrows(X) or rethrows throws(X) then at least the caller knows what they're actually getting, and can hopefully accomplish what they want with that.

Sum types (or equivalent) would be a bit better again. Combined with typed errors, that lets you explicitly express whatever conceivable type behaviour you actually want, as a 'rethrowing' function - e.g. any of:

  • (type-wise) I throw only whatever my closure(s) throw, or
  • I throw only this other error type (a wrapper maybe, with extra info I add), or
  • I throw either whatever my closure(s) throw or something else.

As far as I can see there's perfectly valid (and used) use-cases for each of these, which is no surprise given how fundamental errors & their handling is to any programming language.

I don't see a good way with just these mechanisms to express "I throw exactly the error instance my closure(s) throw", or the "pure rethrows" case. I'm intrigued by @dmt's suggestion of named errors.

Or maybe a lateral solution will prove best?

e.g. what if the Error protocol added some mechanism to add arbitrary key-values to an error, so you don't have to wrap errors and change their types for some common operations?

Or, what if there were a way to dynamically subclass (essentially if not literally) the error type of the closure so that you can add your own properties and/or overrides without breaking callers that catch based on the [now parent class] type? Kinda implies errors become either actual classes (seems like a horrible idea - enums as errors is awesome) or some special new metatype which is a hybrid of enums and classes, or somesuch. A very left-field idea, sure - just brainstorming.

1 Like

I don't think rethrows(E) means anything. How would it differ from throws(E)?

I'm not sure this warrants a new 'dynamic inheritance' mechanism. You could instead define a generic type conforming to Error, so then you'd pass a throws(E) closure to a function that is itself throws(ExtraStuff<E>).

I just remembered two more things about rethrows I dislike.

  1. It's a property of the function decl, and not of the function decl's interface type (which is a function type). This means that if you take an unapplied reference to the function decl, the type of the resulting value loses that it was rethrows:
func f(_: () throws -> ()) rethrows -> () {}

let fn1: (() -> ()) -> () = f  // error
let fn2: (() throws -> ()) throws -> () = f  // okay
  1. It doesn't work with default arguments:
func f(_: () -> throws -> () = {}) rethrows -> () {}
f()  // error: call not marked with `try`

Typed throws doesn't have either limitation. With regard to the first, the partial application applies substitutions, so our type parameter E becomes any Error, Never, or whatever else. Since () throws(Never) -> () is actually () -> (), it works out.

The second case similarly falls out from SE-0347; if the default argument is of a more constrained function type (in this case, it has E := Never) then this becomes the default binding for E, and thus the call f() type checks without a try.

8 Likes

IIUC, both of these would be resolved for typed throws by the originally pitched design here as well, in that the type of f would by default rethrow only the inferred generic type E1 that the closure throws.

This is part of what I was referring to when I replied that I thought the originally pitched design—though more elaborate—so deftly balanced bringing improvements to the table without going back to the drawing board with rethrows.

That doesn't help (for the purpose @jrose brought up), as any caller catching types of E itself won't match ExtraStuff.

Oh gosh, this was a long time ago. In the PR discussion you'll see I shared an example of a rethrows function modifying the error being thrown

This example actually comes from code in my PMJSON framework which did exactly this:

In fact, this code is the original motivation for the change.

(warning: if you look at the code history of that repo you'll find my deadname)

3 Likes

In an attempt to frame the discussion a bit, I think it's probably fair to say that in the absence of an non source/ABI breaking way to reduce rethrows to sugar for typed throws, no one appears to be arguing for its removal (or more accurately its demotion to 'sugar').

So it now appears we're discussing whether we should keep the typed rethrows enhancements as described in the current proposal.

Personally, I feel that this enhancement to rethrows is significant enough and separate enough that it would merit its own separate follow-on proposal.

It seems it would be entirely possible for typed throws to move forward by keeping rethrows as-is for now, (i.e. rethrows can only throw either Never or any Error) and revisiting later should the particular runtime semantics of a typed rethrows be something people actually want.

3 Likes

If a key tentpole motivation for type throws is to enable the use of the same error handling mechanisms in Embedded Swift as in general-purpose Swift, then "keeping rethrows as-is" would mean an error handling design for Embedded Swift that supports throws but not rethrows.

To my mind, this is tantamount to introducing a different error handling design for Embedded Swift by omission, and from the language design standpoint would be as significant a choice as supporting typed rethrows. For this reason, I would not characterize the rethrows portion of the pitch as "separate enough that it would merit its own separate follow-on proposal": rather, separating it out of the typed throws implementation is an equally weighty design choice.

5 Likes

I wouldn't consider it equally weighty, if only for the reason that if it turns out to severely hamper the utility of typed throws we can always revisit the discussion of typed rethrows without concerns around source/ABI compatibility (whereas making the wrong design decision for typed rethrows now and trying to walk it back later is likely to be a much bigger pain). If typed throws can satisfy 90+% of today's use cases for rethrows, I think it's perfectly reasonable to say "let's hold off and see if we actually end up feeling like we need" typed rethrows.

One downside I can forsee is that typed throws doesn't sufficiently handle the concretely-typed case, where you have:

func f(_: () throws(SomeError) -> Void) throws(SomeError) { ... }

Here, the natural type inference that would bind a generic error parameter E to Never in the case where a non-throwing function is passed doesn't apply, so this won't behave like rethrows at the use site. I don't have a good sense of how common this pattern might be, though. With most uses today of rethrows it seems appropriate for them to be generic over the error type, but I have no idea by what margin.

4 Likes

Yes, exactly.

If a function is not generic over the error parameter of its closure, then semantically it would seem it's not a rethrowing function – just a throwing one – and behaving as it should.

Sure, but without rethrows here we have ruled out the ability to simultaneously express both:

  • The function you pass in must throw this specific error type (if it throws at all)
  • If you pass a function statically known to not throw, then the higher-order function also won't throw

which is to my eye a more dramatic downgrade in utility than "you cannot rethrow a different error type if you use typed throws."

I'll admit I don't have a super compelling motivating example for such an API off the top of my head, but it also doesn't strike me as something that is patently absurd or that we'd want to take a principled stand on not supporting.

1 Like

Is that not what this means:

func foo<E>(arg: () throws(E) -> ()) throws(E)

…given E can be Never?

I’m talking about the case where SomeError is a concrete type, i.e., you specifically don’t want to make the higher-order function generic over the thrown error type.

1 Like

Right, I see what you're saying but in my mind this is another example of 'here's something you can do with rethrows so we must be able to do it with typed throws*. I'm not sure that's the right way of looking at it because that's functionality that people may not even use.

With the example of a concrete type, you can also plainly raise errors of that type without calling the supplied closure. You can't do that with rethrows either.

I think it requires zooming out a bit.

For example, the way rethrows works today wasn't down to design, but because there was no other way of doing it. In @Lily_Ballard 's words describing why rethrows needed to throw any arbitrary Error.

So, the way rethrows works today wasn't due to design but due to limitations in the language at the time of implementation. Those limitations don't exist today. So why continue as if they do?

Yeah, but there are many ways to express the same without rethrows:
In today's Swift:

protocol _SomeError {}
extension Never: _SomeError {}
extension SomeError: _SomeError {}
func f<E: _SomeError>(_: () throws(E) -> Void) throws(E) { ... }

or more robust with overloading

func f(_: () throws(SomeError) -> Void) throws(SomeError) { ... }
func f(_: () -> Void) { ... }

In the future sealed protocols might be supported to make the first one even better.
Or union types (one of the variants):

func f<E: SomeError ∪ Never>(_: () throws(E) -> Void) throws(E) { ... }

IMO, rethrows is the least generalized solution among the theoretically possible. It's not compiler enforced well enough. It doesn't allow to specify the source of errors (in case of more than one closures). It doesn't have flexibility in restriction of relation between "in" and "out" errors.

1 Like

I wouldn't put it that way. As @Slava_Pestov has very eloquently laid out, he disagrees with rethrows as a dynamic property describing the runtime behavior of a function: this is the clearly documented design intent, and it is a choice that was by no means due to having "no other way of doing it."

As a matter of procedure, the way I look at it is: If you can do something with rethrows now, omitting it from the design of typed throws is an explicit decision. If we argue that typed throws and rethrows are separable topics, then redesigning-by-omission isn't appropriately part of the scope of this proposal; and if we argue that they are interlinked, then we shouldn't be saying that the alternative to omitting rethrows (that is, supporting it) is an enhancement that can be separated out for later.

2 Likes

Well, I'm giving my interpretation of what I read in the PR that was linked to and is available for anyone to read. I have no insights into the design process other than what's been made available, so I apologise if I misrepresented anything there. I'd be very open to seeing a design rationale if you think it would make the case.

I reject the characterisation that this would be 'redesigning-by-omission'. No redesign would take place at all – rethrows would remain as-is, as would the opportunity to extend it. If the community believes typed rethrows has merit as an idea, it would ascend through the evolution process – just like anything else.

This reads to me as a rejection of incrementality, and I don't think the conclusion that we must tackle everything error-related at once or else not at all is warranted. rethrows is a narrow part of the error-handling story in Swift, and I think there's been a compelling case made that it may not even be necessary in a world with typed throws. Punting on more difficult or controversial design questions until we have further experience with the base feature is a perfectly reasonable, and IMO wise, decision. A case should be made that without typed rethrows we are at a reasonable resting place, but I think that bar is handily cleared: there are plenty of use cases for typed throws that aren't implicated at all by decisions about rethrows. Indeed, I'd expect a sizable majority of use cases to fall into this bucket.

1 Like

This is a fine case to make: My point is that this proposal cannot be separated from either making that case or otherwise offering a design for how rethrows works with typed throws. Put another way, punting on rethrows for typed throws is a decision on rethrows based on substantive design considerations, not merely procedural chunking of a large feature into separate proposals for reviewability.

True, to the degree that you cannot achieve those goals with a specific struct or enum type, only a protocol or class. Which might be sufficient for some uses but seems unhelpful for situations like Embedded Swift where you're trying to avoid existentials or similar overheads.

The magic of Never

Note also, for anyone not aware of this special behaviour, Never can be used as the concrete type for a generic irrespective of the generic's requirements. So you can do things like:

class MyType: Error {}

func foo<T: MyType>(arg: () -> T) -> T {
    return arg()
}

foo { fatalError("Totally fine.") }

Although this magic isn't fully coherent - e.g. try saving the result of foo into a let constant:

let a = foo { fatalError("Totally fine.") }
// Warning: Constant 'a' inferred to have type '()', which may be unexpected.

So you make it explicit, and now it's a compiler error:

let a: Never = foo { fatalError("Totally fine.") }
// Error: Global function 'foo(arg:)' requires that 'Never' inherit from 'MyType'

It's not just that this errors, but the way it errors suggests something's not quite right about how Never is implemented in the type system.

FWIW, you can make it work, by using () as the type instead of Never, which just further shows how wonky Never is.

(I mean, in practice I have few complaints - Never mostly works fine in my experience - but it is interesting to probe the edges with Never and see where things start to fray)