Typed throw functions

Yes this is the hardest case of all. And I added it yesterday. :smiley: I'm more and more convinced that we need to take error conversions into account when we write this proposal. We need to think it through before updating throws

But I disagree that nothing would improved by it comparing to Result.

See this example:

struct Kids {}
struct Spouse {}
struct Cat {}
struct Family {
    let kids: Kids
    let spouse: Spouse
    let cat: Cat
}

struct KidsError: Error {}
struct SpouseError: Error {}
struct CatError: Error {}

enum FamilyError: Error {
    case kidsError(KidsError)
    case spouseError(SpouseError)
    case catError(CatError)
}

// With `throws`

func callKids() throws /* KidsError */ -> Kids { throw KidsError() }

func callSpouse() throws /* SpouseError */ -> Spouse { throw SpouseError() }

func callCat() throws /* CatError */ -> Cat { throw CatError() }

func callFamily() throws -> Family {
    let kids = try callKids()
    let spouse = try callSpouse()
    let cat = try callCat()
    return Family(kids: kids, spouse: spouse, cat: cat)
}

func callFamilySpecificError() throws /* FamilyError */ -> Family {
    do {
        let kids = try callKids()
        let spouse = try callSpouse()
        let cat = try callCat()
        return Family(kids: kids, spouse: spouse, cat: cat)
    } catch let error as KidsError {
        throw FamilyError.kidsError(error)
    } catch let error as SpouseError {
        throw FamilyError.spouseError(error)
    } catch let error as CatError {
        throw FamilyError.catError(error)
    }
}

// With `Result`

func callKidsResult() -> Result<Kids, KidsError> { Result.failure(KidsError()) }

func callSpouseResult() -> Result<Spouse, SpouseError> { Result.failure(SpouseError()) }

func callCatResult() -> Result<Cat, CatError> { Result.failure(CatError()) }

func callFamilyResult() -> Result<Family, FamilyError> {
    return callKidsResult()
        .mapError { error in FamilyError.kidsError(error) }
        .flatMap { kids in
            callSpouseResult()
                .mapError { error in FamilyError.spouseError(error)
            }.flatMap { spouse in
                callCatResult()
                    .mapError { error in FamilyError.catError(error) }
                    .map { cat in
                        Family(kids: kids, spouse: spouse, cat: cat)
                }
        }
    }
}
2 Likes

Personally I prefer untyped errors after 8 years in Java. Simple example: you change something in the implementation and lets say now can throw some file system error. But for some reason you can not update the protocol that limits you in types of Errors you can throw. So you start to either throwing some kind of unknownError, or trying to find the way to wrap it somehow and propagate it up. If you need something descriptive - use Result. Throws basically means that Anything can go wrong, so deal with that accordingly.

2 Likes

Personally I prefer untyped errors after 8 years in Java.

Thanks for your view on this. I heard this conclusions a lot from the Java community. What I don't understand:

If you need to throw it, because you think your API user needs to know, that a FileSystemError did happen, then it's a breaking change, because your API user may want to react to it in another way now.

If you don't need to throw it, because you think your API user does not need to know it, then you map it to the error that represents the FileSystemError.

If you think you don't know what will happen in the future, then just throw Swift.Error. But maybe I don't use your API then because you are not clear enough about what can happen. Or I will just do a general catch because nothing else is guaranteed.

Did I forgot a case?

2 Likes

Some examples, why sometimes we prefer Result over type erased error is working with network errors. I can show some examples:

/// Allows us to enumerate all erros
public enum NetworkError: ConcreteBaseError {
  case urlSessionError(error: URLSessionError, response: DataTaskHttpResponse?)
  case httpStatusError(error: HttpStatusError, response: DataTaskHttpResponse)
  ///...
}

/// Allows us to switch over errorCode
public final class HttpStatusError: ConcreteBaseError {
  public let errorCode: HttpStatusCode

  ...
}

public enum Http400StatusCode: Int, HttpStatusCodeType {
  case badRequest = 400
  case unauthorized = 401
  case paymentRequired = 402
  // ... 
}

But the best benefits come when handling domain errors. The list of these error codes is finite. We use an exhaustive switch statement. It is very important, that if the list of error codes changes, the compiler help us to handle all pieces of code that rely on these error codes.

public enum NetworkDomainableError<DomainCode: DomainCodeType>: ConcreteBaseError {
  /// The usual network layer error
  case networkError(NetworkError)
  
  /// The domain error from the backend. It is not a kind of usual Network error. It is a kind of business logic error
  case domainError(DomainCode)
}

/// Checkout domain codes from backend
enum CheckoutNewOrderErrorCode: String, DomainCodeType {
  case orderPriceMoreThanMaximum = "order_price_more_than_maximum"
  case limitationsNotSet = "limitations_not_set"
  case orderPriceLessThanMinimum = "order_price_less_than_minimum"
  // full list is much biger ...
}

Note, that each error code leads to completely different behavior. We can close the screen, or offer the user to chgande the payment type, or notify the user that some product in his cart is not available anymore ...

Dealing with type erased error in such cases is a nightmare.

Some more examples, that are not connected with networking. These errors are created in local app services / managers / controllers:

func refreshThrottlingResult() -> Result<Void, ThrottlingError>

func validate(_ text: String) -> Result<Void, EmailValidationError>

func asDomainResult(dto: Self) -> Result<Darkstore.SearchItem, MappingError>
// in some cases, MappingError is sent to Crashlytics

Though, we use throwable functions a lot. For example, when dealing with Keychain, thrown Error is enough. We don't care what exactly this error is.

3 Likes

Seems to me like a good example of how you prefer Result over throws just because you can type the throw that you're making. That shouldn't be like that.

I don't really understand the pain here. Why can't you just cast at runtime and then switch over the result?

if let error = error as? MyDomainError {
  switch error {
    case ...
  }
}

You still get exhaustiveness checking.

That's why: swift-typed-throws/SE-XXX Typed throws.md at master ยท minuscorp/swift-typed-throws ยท GitHub

1 Like

The poster I responded to was speaking specifically of domain errors. I find it unlikely that their developers would forget to check their own types.

Then likeliness hit us too often in some of my projects (especially in more complex projects). Your experience may vary and you should not be forced to use explicit errors, but we want to have the possibility to use them.

2 Likes

One of the older posts described that problems in the other way can also happen. If a later implementation of a Java function purges an exception type, you still have to keep that type in the function's signature's exception list forever to maintain backwards compatibility.

The desired async/await feature has an interaction model that is very similar to throws/try. So much so that sometimes we wonder if we should have a general effects feature and put a/a, and move t/t, under that. How would typed-throwing work as an effect? If we want both, maybe we should implement typed-throwing after general effects.

3 Likes

In Java, typed exceptions are especially bad since you are encouraged to use interfaces as an extensibility method, such as to integrate a third party system. An implementation that returns data from the network will have network-related errors, but the interface itself most likely does not define those as potential exception types.

In Java 1.4 they added the inner exception concept to built-in exceptions to deal with this pain point, since without wrapping the exception you'll lose context of what actually went wrong. However, the problem still exists that a lot of third party exceptions do not expose the ability to initialize with an innerException, so you are forced to figure out how to translate your error into the built-in system, and potentially lose the ability to detect and report issues outside the interface - for instance, see that there was an authentication error if you supply a JSON parser with data via a HTTP client.

Just the reportability of what went wrong can't necessarily be represented in the error types exposed by the interface, which IMHO is one of the reasons that a cross-cutting logging solution became essential even for libraries.

Swift will have this problem worse than Java if we do this wrong - there is no way to extend a concrete Error subtype from a third-party library to add 'innerError' / 'cause' information.

1 Like

Maybe we could have a new protocol, PossiblyWrappingError, that refines Error. It defines an access point for an inner-cause (Optional) error. Typed-throws would require the explicitly-listed error types to conform to the new protocol.

I'm a little bit -0.5 to this typed-throw design conception.

We already have Typed Result<Data, Error> and Untyped throws -> Data why we need another typed throws Error -> Data approach, I know maybe it's helpful in some cases, but overall there's NO huge benefits/changes for developer to deal with exception handling.

Otherwise, if using throws Error -> Result<Data, AnotherError> pattern, is it meaningful or just a way of duality of error propagation.

1 Like

async and throws are both monadic, but having separate keywords is more readable from my first intuition

It just means you won't throw it anymore and the client needs to handle to be compatible, but that's like a function argument you are not using anymore that you maintain for backward compatibility. If you want to clean up you need to break the contract or introduce new API.

1 Like

If one removes a type from the list of thrown errors, will the compiler complain that an unexpected error is being caught at the call site?

There is no "list of thrown errors" at the moment. I think we need to make the draft ready, because a lot of the things discussed here are explained in the draft.

Yes. The "one type" rule would include singular types like you describe. Such a thing forces complexity into existing language features, e.g. building on all the compatibility, subtyping rules, etc that go with it. My whole argument here is to give decision making power to API authors, since they are the ones that know the best thing for their domain.

I'll be more explicit about this, just to make my position clear. I think that all of these should work:

  1. func foo() - already supported, nothing thrown.
  2. func foo() throws - already supported - can throw anything conforming to error.
  3. func foo() throws T - should be supported IMO - can throw T. Unclear whether T should be forced to conform to Error. It doesn't seem necessary, but maybe a good thing for other reasons.
  4. func foo() throws MyEitherType<T,U> - This is the same thing as #2.

I do not thing we should support something like "func foo() throws T, U". I also don't think we should recommend #3 for APIs, I just don't think we should ban it in the language or fret about it too much.

Separate from typed errors, I also do not think Swift should introduce | syntax for "either" types - this has been discussed in the past, and is more nuanced than it appears at first. Such syntax would also probably lead to confusion in generic parameter constraints (T == SomeType|OtherType wouldn't do what people think). In any case, such a discussion is orthogonal from typed errors and should probably be split off to its own thread.

-Chris

14 Likes

T probably should conform to Error, so functions with a typed-throw are subtypes of functions with the general throw. Further, I mentioned in another post that T should conform to a refined OptionallyWrappingError, to standardize what to do when the author's "can only throw T" function has internals the suddenly throw an unanticipated error type.

3 Likes