Typed throw functions

Besides explicitly specifying what can go wrong, typed throws can be very useful when there is some special custom error type in code (application, library, cli tool), that top level code knows how to display to user. For example, writing a command line utility, it's nice to write to console what went wrong:

try {
  runTool(args)
} catch let myErr as MyError {
  processError(myErr)
} catch {
  print(error)
}

The problem with this approach that every once in a while some NSError from some file operation is sneaking into default catch, producing bad, reflection based description. Localised description doesn't help either, because it lacks important information (for example "No such file or directory" error gives only filename, no path information).

This problem exists, for example, in swift-driver, requiring tedious tracking of all "foreign" errors, without typecheker to help (I don't know how big is an issue it is there, but I wrote all my cli tools in same style, with do-catch clause at top level).

I'm not saying that it should be fixed with typed errors there, it's always a trade between profit and amount of boilerplate it requires, but it would be nice to, at least, have an option to move this tedious work to typecheker.

By far the most useful and interesting aspect of throw in my mind is that errors thrown at different levels in a nested call structure are gathered up and exposed in the same place. "Railroad oriented programming" automatically. How would that play with typed errors?

func a() throws ??? {
    if ... { throw TypeAError.something }
    try b()
}

func b() throws TypeBError {
    if ... { throw TypeBError.somethingElse }
}

Without this railroad aspect of throw is it really much different from using Result?

I think one obvious solution be to either propagate the type (throws SomeError), in which case calling functions that don't throw that error would be a compiler error, or the type would automatically be erased at the first "car" that doesn't type its error (throws).

1 Like

As far as I understand your issue, you would need to wrap this API and convert to specific type checked errors to move more information into the type system. How much information that would be is your decision. But yes first we need to have the possibility to do that. :slight_smile:

If I understand you correctly, that wouldn't cover the case I'm talking about, what you describe sounds like how normal return types work, but errors are different, they can propagate several call levels at once, like in my example. Calling a() could yield either a TypeAError or a TypeBError-

Good observation. I mentioned some aspects of it here: https://github.com/minuscorp/swift-typed-throws/blob/master/SE-XXX%20Typed%20throws.md#result-is-not-the-go-to-replacement-for-throws-in-imperative-languages

But your specific point came up some months ago when I used Result and I started thinking about a solution, so maybe this post is for you: Question/Idea: Improving explicit error handling in Swift (with enum operations)

This proposal is the first step in making explicit errors with throws more usable. As some commenters already pointed out there is a challenge in applying error conversions/accumulations (maybe inferring them). But let split that up and discuss the error converting specifics in the post I mentioned above.

2 Likes

If you wrap your try b() you could handle its error and propagate the same error that you were raising, but that's up to how you'd want to do things.

Sure, but then the unique advantage of throws over Result disappears.

Not really, as you'd still have the other advantages of throws in syntax and code generation. Result has the same issue here.

1 Like

It is just all about semantics sometimes. You might be in a throwing context they you don't want to switch into a Result one just because you cannot achieve same results with throws. This proposal is also about making throws being able to be used as well as Result. Which doesn't nowadays.

For now you would need error type conversions like we said here: https://github.com/minuscorp/swift-typed-throws/blob/master/SE-XXX%20Typed%20throws.md#error-type-conversions

So yes it is just the first step. But I could think of an auto generated enum derived from try uses in a block of code. But yes, just brain storming for now. :smiley:

Hm I do think error type conversion is the big weakness here. Either you'd need combined types like you mentioned, or the explicit type conversion will be just as ugly as the explicit mapping of Result that you set out to solve. And in the case of explicit conversion, you'd still have the issue that the error type in the calling function (callFamily) would have to repeat the error cases of the called functions (callKids etc).

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

I think an elegant solution for this would have to be part of the proposal, otherwise your own examples aren't really improved by it!

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: https://github.com/minuscorp/swift-typed-throws/blob/master/SE-XXX%20Typed%20throws.md#potential-drift-between-thrown-errors-and-catch-clauses

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.