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`
}

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
}
1 Like

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

1 Like

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

2 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.

2 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.

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?