TSPL Pitch: Typed throws

The majority of this update will be a new section in the Error Handling section of the guide โ€” minor updates are needed throughout the reference and grammar. Here's the high-level outline:

  • Contrast between throws and throws(MyErrorType) and throws(any Error)
  • When to use typed throws
  • The rules/behavior for inferring what type is thrown, and when you can let the compiler infer
  • How to exhaustively handle typed errors in a catch block

For a detailed outline and the code example I plan to use, please see the diff linked below:

4 Likes

Looking good, still excited to see this coming to the language A couple of points:

  • Is it worth mentioning that a function that throws Never is equivalent to a non-throwing function? i.e func f() throws(Never) -> T is equivalent to func f() -> T. Perhaps an example of why this is useful, and how rethrows can be achieved with type throws (and is possibly preferred)?
  • Is it worth labouring a bit more that regular throws is still preferred, and that package authors should still keep the surface area of their APIs as small as possible for the greatest flexibility. i.e. avoid encoding (nesting) the concrete errors of their dependencies in their own errors if possible. At one point or another, abuse of typed throws was a big concern:

Emphasis added. This was a while back now, so maybe in the light of day those concerns have softened somewhat.

3 Likes

Thanks โ€” yes, the docs need should point out specifically that throws(Never) is the same as not throwing.

By "laboring a bit more" were you suggesting that the new section in the guide should talk about this in more detail, or that it should be shorter to avoid belaboring the point?

1 Like

I mean labouring in the positive sense: emphasising the point that regular untyped throws is still preferred and why.

Hi @Alex_Martini,

I am providing this feedback as someone who is only familiar with the gist of typed throws from a high level.

First, overall, I found the outline clear and just reading the outline I feel I have a much better understanding of typed throws.

  1. I strongly agree with @tcldr that it is important to emphasize that most code does not need to use typed throws, and possibly spell out why using untyped throws is preferred.

  2. In the list of reasons why you would use typed throws, it wasn't clear to me why this would be one of those cases:

    • In code that only rethrows errors, especially when the throwing code comes from a closure the caller provided. Example: map in the stdlib. Xref to reference section -- this chapter doesn't discuss rethrows
  3. In the discussion of "You can also use opaque types like throws(some MyErrorProtocol)", possibly clarify that you can't dynamically return different types of MyErrorProtocol at runtime. (Although I realize that is more of an opaque type topic than a typed throws topic.)

  4. For me, the biggest unexpected syntax was seeing throws as part of do syntax:

    do throws(SomeErrorType) { ... }

    I was definitely expecting some change to throws in function declarations.

    Maybe some language that mentioned how Swift has always used do statements in cases where you want to catch thrown errors in your own code, and that now with typed throws, declaring a do block has been expanded to include this new syntax.

    (And I am guessing that you can't write do throws { untyped errors, the throws is implied.)

  5. Elsewhere in TSPL it is stated that ' Every switch statement must be exhaustive .' and that includes switch statements that include a default case.

I think following that conceptual model, untyped throws can be handled exhaustively, if a general catch / default is provided, but it is only required by typed throws.

Definitely looking forward to the first fleshed-out draft!

1 Like

That's good feedback. What it's trying to convey is that typed throws can be a partial replacement for rethrows, by codifying that the type(s) of errors thrown come from e.g. a closure argument (but it's even more powerful than that, because they can come from an associated type and other places too, unlike normal rethrows).

Unfortunately it can be a confusing subtopic because it's not a superset of rethrows, and typed throws are not currently fully compatible with rethrows.

Thank you for your explanation.

So, if I understand correctly, if I had a self-contained subsystem with its own set of errors implementing a protocol like MySubsytemError, it might be useful to write a method like this:

func runsAClosure(_ closure: () throws(some MySubsytemError) -> String) throws(some MySubsytemError) -> String

Where, instead of rethrows the function that takes a closure argument can report the type of the error it will throw?

But, the standard library map() would not be able to use typed throws, since it already uses rethrows?

Is that at least in the ballpark of correct?

It should be:

func runsAClosure<E: MySubsystemError>(_ closure: () throws(E) -> String) throws(E) -> String

That way you know that the thrown error is exactly the type that the closure throws, not merely some [potentially different] error that also conforms to MySubsystemError.

The difference with rethrows is that the above merely restricts the type of the [re]thrown error, it places no restrictions on when an error can be thrown. rethrows actually enforces that runsAClosure can't throw unless the closure throws (sans some bugs and somewhat intentional escape hatches).

The two don't play well together yet because the combination simply isn't implemented. It's not clear if it ever will be. Some folks want to get rid of rethrows completely, rather than evolve it into the typed errors regime, even though that loses its unique functionality.

This is somewhat tangential to the discussion at hand (documenting typed throws), but the documentation probably does need to at least note the Venn-like relationship between typed throws and rethrows, and the current limitations of trying to combine them, and provide guidance on when to use one or the other.

Thank you @wadetregaskis - yes that makes sense, thank you.

From the perspective of the documentation and that particular bullet point:

  • In code that only rethrows errors, especially when the throwing code comes from a closure the caller provided. Example: map in the stdlib. Xref to reference section -- this chapter doesn't discuss rethrows

For me, the mention of map() in the standard library as an example of this point was confusing.

I realize now, it isn't trying to say that map() can use typed throws, but that typed throws could be useful when writing a function that might otherwise use rethrows.

Right. Though if map does switch to typed throws, that ambiguity will be eliminated.

Though until then, it might be better for the documentation to pick a different example - one that does in fact use typed throws already.

1 Like

The standard library is adopting typed throws for map: this is part of the approved typed throws proposal.

2 Likes

While I agree with it in a whole, I'm also sure we need not only to claim "most code does not need to use typed throws", "typed throws for those who understand what they are doing", "types throws are for special concrete needs" and so on. Documentation should provide real world examples, meaningful explanations, clear guidelines and more importantly when / why typed throws can cause tangible disadvantages.

Just one example from my current project: while we use type erased any Error / some Error across module boundaries in general, in module internals and more often in screen internals (privately) Typed Result<T, ConcreteError>s are used, specifically for Network Requests. Each request returns Result<T: Codable, NetworkError>. Network error is a enum with following cases:

  • urlSessionError
  • httpStatusError
  • mappingError
  • other (used only in a couple situations and is very uncommon).

As our product evolve very fast, it is a typical situation when product requirements are changed and we need to show different UI layout, user messages and suggest different actions to user for different http status codes. Worth to mention all of it is also often differs for urlSession / httpStatus / mapping errors.
If we begin to use type erased errors by default and switch to concrete error types when they are really needed then we simply get a regression in time to market with no benefits to codebase. During migration process to modern concurrency these Results will partly turn into typed throws.
In different projects I saw a lot of clunky code where erased any Errors were casted to concrete types almost every time with pointless else {} / default: code branches for handling type casting failure which should never happen. Dynamic casting perfomance is not a problem at all for our project, but may be reasonable for others.

It just as with struct and classes where people should understand for which cases each one is preferred. But error handling in general is a less undestanble topic.

3 Likes

My own observation is that people tend to avoid using of difficult features like generics and very easily begin to use simple (by their opinion) features like new concurrency in a wrong way (e.g. spawning tons of Task {}, Task.detached {} and marking lots of non-UI code with @MainActor).
I feel like something similar can happen with typed throws. I observe that developers often want to use concrete Error types for different reasons, but I would better say misconceptions:

  • as users of strong-typed / statically-typed language, error types should also be strict (e.g. if we typically don't use Array<any BinaryInteger>, why should Array<any Error be preferred to Array<ConcreteError>)
  • even we don't need concrete error type, it will be helpful if something in future will change
  • don't need to type cast any Error and related burden
  • impossibility or hard solving of a task if concrete error type is not visible in public namespace and therefore can't be typecasted, which involves raw integers and string literals comparisons with lots of if-else
    ...

These are the common reasons for what I believe we should have clear explanations about typed throws.

Al in all, the feature itself is very attractive and amazing!

In code that has no dependencies, and only ever throws its own errors.
โ€“ own errors are also might not need to be exposed. A think throws(some Error) may have sense for both desktop and embedded swift.

extension NonEmptyOrderedDictionary {
  public init(from sequence: some Sequence) throws(some Error) {}
}

public struct HexColor: Hashable {
  ...
  public init(hexColorString: String) throws(some Error) { ... }
}

// Are there any reasons for typed throws here?

TR: Does this include not depending on the stdlib?
Is there any way to not depend on stdlib?

Thanks all for the feedback! I've made some minor changes to the outline and I'll keep these comments in mind as I write the first draft.

2 Likes