Typed Throws with Protocols

As typed throws are bouncing around my mind, I'm starting to envision functions that can only possibly throw errors that are relevant to the specific function being called.

A contrived example:

extension Data {
	struct InvalidByteCount: Error {}

	func convert<T: BinaryInteger>() throws(InvalidByteCount) -> T {
		guard
			MemoryLayout<T>.size == self.count
		else { throw InvalidByteCount() }

		return withUnsafeBytes { pointer in
			pointer.load(as: T.self)
		}
	}
}

This gives me and the user an extremely clear means of knowing what can go wrong with a given input and a much narrower field of contingency plans to deal with errors. I think we all understand that :)

However, what happens when I call this from another method that has its own, potential unique errors? (another more contrived example):

extension Data {
	struct InadequateDataForRequestedTotal: Error {}

	func extractCountedArray<T: BinaryInteger>(total: Int) throws -> [T] {
		let size = MemoryLayout<T>.size

		let requiredByteCount = size * total

		guard
			count >= requiredByteCount
		else { throw InadequateDataForRequestedTotal() }

		var accumulator: [T] = []
		for offset in stride(from: 0, to: total, by: size) {
			let slice = self[offset..<(offset + size)]
			try accumulator.append(slice.convert())
		}

		return accumulator
	}
}

Now I have two different error types and have to use the generic throws.

I am completely aware I could use an enum to cover all the possible errors for this suite of functions, but then I would have the potential to throw .inadequateDataForRequestedTotal from the convert method.

extension Data {
	enum ExampleErrors: Error {
		case invalidByteCount
		case inadequateDataForRequestedTotal
	}

	// pretend the previous examples are rewritten to use this error enum instead

	func exampleCallSite() {
		do {
			let value: Int64 = try data.convert()
		} catch .invalidByteCount {
			// handling code
		} catch .inadequateDataForRequestedTotal {
			// this makes no sense. The method has no path to throw error, yet I still have to handle it.
		}
	}
}

What are other people doing for this scenario? Any good solutions? Or are we still figuring this one out?

I briefly had the idea to constraint protocols together, which I concluded probably wouldn't even work in the end, but this doesn't even compile... Shouldn't it at least compile?

extension Data {
	protocol ConversionError: Error {}

	struct InvalidByteCount: ConversionError {}

	func example() throws(ConversionError) {} // gets compiler error `Thrown type 'any Data.ConversionError' does not conform to the 'Error' protocol`
}
2 Likes

If no one else has any ideas, I've come up with a solution that looks like this:

enum A: Error {
	case a
}

enum B: Error {
	case a
	case b

	enum Result<T> {
		case success(T)
		case failure(B)

		init(_ block: @autoclosure () throws(A) -> T) {
			do {
				let result = try block()
				self = .success(result)
			} catch {
				self = .failure(.a)
			}
		}

		func get() throws(B) -> T {
			switch self {
			case .success(let t):
				return t
			case .failure(let error):
				throw error
			}
		}
	}
}

func callSiteExampleA() throws(A) {
	throw .a
}

func callSiteExampleB() throws(B) {
	if Bool.random() {
		try B.Result(callSiteExampleA()).get()
	} else {
		throw .b
	}
}


func nonThrowCallSite() {
	do {
		try callSiteExampleA()
	} catch {
		switch error {
		case .a:
			print("got 'a' error")
		}
	}

	do {
		try callSiteExampleB()
	} catch {
		switch error {
		case .a:
			print("got 'a' error")
		case .b:
			print("got 'b' error")
		}
	}
}

I'm not sure how I feel about it. It's a bit repetitive, but it DOES work. I suppose I could also have a B case with an associated type A.

Anyone else have any thoughts?

P.S.
I was surprised to find that

do {
	try callSiteExampleA()
} catch .a {
	print("got 'a' error")
}

gets the error Errors thrown from here are not handled because the enclosing catch is not exhaustive, even though it is.

SE-0413 (Typed throws) addresses this:

Note that the only way to write an exhaustive do...catch statement is to have an unconditional catch block. The dynamic checking provided by is or as patterns in the catch block cannot be used to make a catch exhaustive, even if the type specified is the same as the type thrown from the body of the do:

func f() {
  do /*infers throws(CatError)*/ {
    try callCat()
  } catch let ce as CatError {
    
  } // error: do...catch is not exhaustive, so this code rethrows CatError and is ill-formed
}
3 Likes

Ah! I missed that. But yeah, that's unfortunate. Thanks :)

1 Like

Was this supposed to be amended in a future language version?

3 Likes

Exhaustive do...catch is supposed to be behind an upcoming feature flag, but the implementation was incomplete and was disabled. AFAIK there's currently no way to enable it. We have to wait for it to be implemented again, which seems to be a low priority.

That's incorrect. The issue described by the OP (you can't do exhaustive pattern matching in a catch clause in the same way you can do in a switch case) was there from the start of SE-0413 and was never intended to be behind the FullTypedThrows feature flag. You'll find the same wording I quoted above in the very first version of the proposal:

Note that the only way to write an exhaustive do...catch statement is to have an unconditional catch block. The dynamic checking provided by is or as patterns in the catch block cannot be used to make a catch exhaustive, even if the type specified is the same as the type thrown from the body of the do

But note that this limitation only applies to pattern matching in the catch clause. The implicit error variable inside the catch block will still have the inferred concrete error type. So, in the OP's example, this is perfectly valid (and equivalent to an exhaustive error check):

    do throws(A) {
        try callSiteExampleA()
    } catch {
        // error is of type A, not 'any Error'
        switch error {
        case .a: print("got 'a' error")
        }
    }

The FullTypedThrows feature flag was only intended to change the behavior of throws statements, not of catch clauses. Again quoting from the original version of the proposal:

To mitigate this source compatibility problem in Swift 5, throw statements will be treated as always throwing any Error. In Swift 6, they will be treated as throwing the type of their thrown expression. One can enable the Swift 6 behavior with the upcoming feature flag named FullTypedThrows.

3 Likes

My mistake. I don't think I've ever really understood the limitation. In what situations is "throw statements will be treated as always throwing any Error" actually true? If it was generally true, I wouldn't expect any typed throws to work, as all throw statements would throw any Error. Is it just for the purposes of closure throw inference? Does it just mean throw someTypedValue() will erase the error type?

1 Like

It's the latter. My understanding is that throw someTypedError will erase the error type, unless there's an outer declaration that explicitly says that we're in a typed throws context. This outer declaration can be either a function with throws(E) or a do throws(E) block.

Here are examples of these three cases (no typed-throws context, typed-throws function, and typed-throws do block). The comments in the catch blocks show the inferred type of the implicit error param:

struct TypedError: Error {}

func throwsTypedError() throws(TypedError) {
    throw TypedError()
}

// No explicit typed-throws context
do {
    throw TypedError()
} catch {
    // error: any Error
}

// Typed-throws do block
do throws(TypedError) {
    throw TypedError()
} catch {
    // error: TypedError
}

// Calling a typed-throws function
do {
    try throwsTypedError()
} catch {
    // error: TypedError
}

This behavior is identical in both Swift 5 and 6 language modes.

2 Likes

This is all very interesting and helpful! It explains a lot of the inconsistencies I've experienced since playing with typed throws in my own code.

Does anyone have any suggestions as to how to go about composing error types in a more concise way than I suggested?

First of all, typed errors are a life saver! I got rid of all the Results in my code and it's incredible how much easier to read it has become - shout out to the authors of this change!

Now, I've been thinking a lot about the issue with aggregating different error types, as it tends to happen quite often when different layers inside the app have their own error types. These types of blocks:

do {
    try someDependency.someMethod()
} catch {
    switch error {
    case .kind1:
        throw .kind1 // Or something entirely different.
    case .kind2:
        throw .kind2
    ...
    }
}

do pollute the code.

Solution with the current state of the feature

What I do currently is separate the logic from calling the dependencies. Consider this example:

// ParentStruct.swift:

struct ParentStruct {

    enum ParentError: Error {
        case kind1
        case kind2
    }
    
    let childStruct: ChildStruct()

    func parentMethod() throws(ParentError) {
        try self.childMethod(childStruct: childStruct)
    }
}

// ParentStruct+child.swift:

extension ParentStruct {

    func childMethod(childStruct: ChildStruct) throws(ParentError) {
        do {
            try childStruct.childMethod()
        } catch {
            switch error {
            case .kind1:
                throw .kind1
            case .kind2:
                throw .kind2
            }
        }
    }
}

// ChildStruct.swift:

struct ChildStruct {

    enum ChildError: Error {
        case kind1
        case kind2
    }

    func childMethod() throws(ChildError) { ... }
}

Possible future solution

I think the best way to "properly" solve this issue would be to introduce a converter parameter to the try keyword syntax. ParentStruct.swift would look like:

struct ParentStruct {

    enum ParentError: Error {
        case kind1
        case kind2
        
        static func fromChildError(_ childError: ChildStruct.ChildError) -> ParentError {
            switch childError {
            case .kind1:
                return .kind1
            case .kind2:
                return .kind2
            }
        }
    }
    
    let childStruct: ChildStruct()

    func parentMethod() throws(ParentStructError) {
        try(ParentError.fromChildError) childStruct.childMethod() // <- Note the new try conversion parameter.
    }
}

The conversion parameter could also be an init or any function that takes the thrown error and returns another one.

1 Like

I like the proposal, if the compiler could support this sort of feature!

I agree that error conversion is sorely missing from the language or at least standard library, but it can be done with a function or operator already.

infix operator ¿? : NilCoalescingPrecedence

/// Transform one error into another when an operation fails.
/// - Parameters:
///   - success: A potentially throwing value to evaluate.
///   - transform: A conversion to a different error type.
/// - Remark: `shift-option-?` *makes the `¿` symbol.*
public func ¿?<Success, Failure, NewFailure>(
  _ success: @autoclosure () throws(Failure) -> Success,
  _ transform: (Failure) -> NewFailure,
) throws(NewFailure) -> Success {
  // This could also be written as
  // try Result(catching: success).mapError(transform).get()
  do { return try success() }
  catch { throw transform(error) }
}
enum A: Error { case a }
enum B: Error {
  case a, b
  init(_ a: A) { self = .a }
}

func callSiteExampleA() throws(A) { throw .a }
func callSiteExampleB() throws(B) {
  if .random() { try callSiteExampleA() ¿? B.init }
  else { throw .b }
}
struct Parent {
  enum Error: Swift.Error {
    case kind1, kind2
    init(_ error: Child.Error) {
      self = switch error {
      case .kind1: .kind1
      case .kind2: .kind2
      }
    }
  }

  func method(_ child: Child) throws(Error) {
    try child.method() ¿? Error.init
  }
}

struct Child {
  enum Error: Swift.Error { case kind1, kind2 }
  func method() throws(Error) { }
}
1 Like

I'm very conflicted. I love this approach, tho I'm very wary of adding additional operators to my code.

Wooow now that's a cool solution! :exploding_head: I honestly didn't know you could use autoclosure that way, although to be fair, I almost never actually use it at all. Time to read up on it.

Do I understand it correctly that because the success param is autoclosure, when you call this:

try child.method() ¿? Error.init

the try child.method() part is converted to a closure?

This will let me significantly improve my code, thanks!

Edit: I've just tried it and unfortunately it doesn't seem to work so great with an async function. In addition to adding an async variant, this is what I had to do at the call site to get it working:

try await (await asyncThrowingCall()) ¿? Error.init

This is because the autoclosure needs the internal await, but also the ¿? operator is async in this case. Do you maybe know a better solution?

Edit 2: it seems that this is a Swift's limitation:

1 Like