Cannot compose APIs that use typed throws

recently, i’ve been giving typed throws a second look, and evaluating places where i might use it in API. so far, i’ve been disappointed.

at a minimum, i would expect to be able to vend a two-or-more layer API that uses typed throws, where one layer calls the other. for example:

func foo<T, E>(
    into initial: consuming T,
    with yield: (inout T, Int) throws(E) -> ()
) throws(E) -> T {
    try bar(into: initial) {
//      ~~~
// Thrown expression type 'any Error' cannot be converted to error type 'E'
        try yield(&$0, 1)
    }
}

func bar<T, E>(
    into result: consuming T,
    with yield: (inout T) throws(E) -> ()
) throws(E) -> T {
    try yield(&result)
    return result
}

but even this minimal example breaks down, you get Thrown expression type 'any Error' cannot be converted to error type 'E'

yes, i know how to fix it — you just need to abandon anonymous closure arguments and write out the full function signature in the closure like this:

-   try bar(into: initial) {
-       try yield(&$0, 1)
+   try bar(into: initial) { (result: inout T) throws(E) -> () in
+       try yield(&result, 1)
    }

but that’s not relevant, i shouldn’t have to know to press this brick in the wall in the first place, i shouldn’t have to explain to my teammates (who are experienced Swift developers!) how to write code that calls this API that compiles, and the fact that i can’t use the abbreviated closure syntax with typed throws anyway is a considerable usability penalty.

skill acquisition for the nuances of typed throws is also problematic. i tried inputting this problem to an LLM, Google Gemini 3.0 Pro, and well, Google Gemini winds up hallucinating a bit, i guess due to how sparsely documented the feature is. the Recommended Solution it gives (add a where E: Error constraint) is completely wrong.

LLM transcript

This is a known limitation in Swift's current type inference system regarding Typed Throws (introduced in Swift 6).

The error happens because the Swift compiler struggles to infer that the closure passed to bar should specifically throw type E. Instead, it defaults to the standard throws (which is equivalent to throws(any Error)), or it degrades the captured yield function to throwing any Error.

Since your function foo and bar require specific typed throws (E), the compiler flags a mismatch when it thinks the expression inside the closure is throwing a generic any Error.

The Fixes

You have two main ways to solve this.

Option 1: Explicitly Constrain E to Error (Recommended)

While Swift 6 allows throws(E) to imply E: Error, the compiler's inference engine sometimes loses track of this conformance inside closures. Explicitly adding the : Error constraint often helps the compiler resolve the types correctly.

Swift

// Change <T, E> to <T, E: Error>
func foo<T, E: Error>(
    into initial: consuming T,
    with yield: (inout T, Int) throws(E) -> ()
) throws(E) -> T {
    try bar(into: initial) {
        try yield(&$0, 1)
    }
}

Option 2: Explicitly Annotate the Closure

If Option 1 doesn't work (or if you can't change the generics), you must explicitly tell the compiler that the closure throws E.

Swift

func foo<T, E>(
    into initial: consuming T,
    with yield: (inout T, Int) throws(E) -> ()
) throws(E) -> T {
    // Explicitly define the closure signature
    try bar(into: initial) { (val: inout T) throws(E) in
        try yield(&val, 1)
    }
}

Why is this happening?

  1. Inference Gap: Swift defaults unannotated closures to throws(any Error) unless the context is extremely strict.
  2. Context Mismatch: bar expects a closure that throws(E).
  3. Conflict: The compiler sees try yield(...). Due to inference limitations in nested scopes, it perceives this call as throwing any Error (the fallback). It then complains that any Error is not specific enough to satisfy the requirement of E.

ultimately, unless the guarantees provided by static typing of the thrown errors were extremely motivating (rare), the usability penalty inherent in a typed throws API is so severe i would probably assess it to not be worth it at all in most situations.

there have been a few other recent threads started by others about Swift features with noticeable usability handicaps (1, 2), but right now i just want to talk about typed throws and ask why it is so challenging to plug one typed throws API into another?

6 Likes

It works if you explicitly type the closure passed to bar, so I can only assume that type inference is not playing well with typed throws. It's probably assuming throwing closures are throws(any Error) aka throws. I'd file a bug report.

1 Like

The first time I experimented with typed throws I was bewildered:

struct E: Error {}

let c: () throws(E) -> Void = {
    throw E() // thrown expression type 'any Error' cannot be converted to error type 'E'
}

let c2: () throws(E) -> Void = { () throws(E) -> Void in
    throw E() // fine
}

That’s simply not the ergonomics and usability I expect from Swift, and I would like to see much improved inference in the near future.

I think for complete typed throws inference, I would’ve liked something like this:

struct E: Error {}

let c = {
    throw(E()) // Closure inferred as: () throws(E) -> Void
}

let c2 = {
    throw E() // Closure inferred as: () throws(any Error) -> Void
}
3 Likes

I''m not sure top level inference like that can be changed to assume typed throws without at least a major language version bump, but it really should infer it when the types have already been clearly stated like in OP's example.

Not exactly. Just the error type. (Which cannot be done using anonymous arguments.)

+   try bar(into: initial) { result throws(E) in
+       try yield(&result, 1)

This has been a bug since the initial feature release. Many bug reports have been filed. The feature has been abandoned.


This is how to do it without the consuming keyword. (It may be possible to use it with consuming, but the idea of documenting how to use consuming also seems to have been abandoned, or was never on the table to begin with.)

try bar(into: initial) { try yield(&$0, 1) } ¿! E.self
infix operator ¿!

public func ¿! <Value, Error>(
  value: @autoclosure () throws -> Value,
  errorType: Error.Type
) throws(Error) -> Value {
  do { return try value() }
  catch { throw error as! Error }
}

That’s true, however typed throws was introduced in Swift 6.0, a major version bump, so we could have done it from the beginning, as I believe the impact on source compatibility would’ve been minimal. I see this as the “final incarnation” of typed throws inference, perhaps we can get it in Swift 7. For now, I would be content if at least my first example would work.

Long story short, we’re missing closure type inference for typed throws and it makes the ergonomics bad. I believe this is a known issue, unfortunate, but known. We’ve not worked on this in the current timeframe but it’s something we’ll have to revisit for sure.

5 Likes

Would you have any estimate how much work this would be? Is there any chance something like this would make a "good first issue" for a volunteer engineer… or is fixing this way more advanced than for someone just learning how to engineer on the compiler?

After consulting with @xedin we think it’d be too difficult for newcomers, and it also depends on cross-statement inference in closures that has to join error types which isn’t something that we have today. So there’s a lot of work here but it’s understood as an ergonomic issue for typed throws.

2 Likes