If/guard-let-catch for conveniently accessing both Result type cases

I'll happily explain my requirements in more detail to show my "safety problems" with throws:

  1. I accept that throwing functions in system libraries or 3rd party libraries I use in my app like String.write(to:atomically:encoding:) could throw any kind of error (although it would be really nice if common ones would be easier to explore so I can choose if I want to handle some). I could never potentially handle them all because I have no control over them and their release cycle is independent from my apps release cycle, so error cases could change anytime making it inconvenient to update my code. So if I call into these, I might catch a few cases I encounter during development (or through users reporting them) and handle them with my custom logic (e.g. presenting instructions to users how they can fix the issue they've encountered). The current throws with catching the types I explicitly want to cover works fine here. Just documentation needs more improvement, I guess.

  2. I do not want to miss handling any of the errors I throw myself in my application, because I want to force myself to provide some useful custom logic to handle every error produced by my own code to make the user experience for my users as smooth as possible. This is really important to me, so I use enum types in different parts of my application and add a new error case for every new assumption I have in code that is not being met by the data I find (mostly caller input data). By returning a Result with my custom error enum I make sure the call side can easily switch over all possible error reasons and act accordingly (either by providing an automatic resolution or presenting the error with clear instructions and error highlighting to my users).

  3. When users come across any of my apps errors that could not be automatically resolved but are just presented with instructions to the users, I want my users to be able to report them to me as questions (so I can help them understand it better & maybe improve the instructions in the app) or as unexpected errors (so I can fix issues in my code when not the user did something weird but my app just had a bug). In any of these cases, in order to be able to investigate the issue, I don't just want to know the specific module the error was encountered, but I also want to know the entire "error call stack" so I know which screen the user was in, which other module this was calling, with other module that module was calling etc. until the error is encountered. To do that, I nest my error types, so an error type of a screen of my app can have 2 local error cases and 5 nested error cases. I define a custom protocol that all my error types need to adhere to so I can build that "error call stack" by concatenating the nested error cases unique identifier to the local error cases unique identifier, prepended by the entry error types unique type identifier, leading to an error code like "CFG-GPF" where "CFG" stands for the error type (so I know which top level screen/module the user was presented with the error) and the "GPF" is a list of nested error cases, e.g. "G: generateEnumFailed(StringsEnumError) => P: parsingError(ParsingError) => F: failed(expectedInput: String, remainingInput: Substring)".

Here's a typical error type in my app:

// `public` because my app is modularized using SwiftPM
public enum ConfigFileGeneratedCodeError: Error {
   // MARK: - Leaf
   case renameResourcesEnumFileFailed(errorDescription: String)
   case cantOpenNonExistentEnumFile(expectedFileUrl: URL)

   // MARK: - Nested
   case writeTempFileFailed(error: ReadWriteFileError)
   case appSandboxAccessError(error: AppSandboxAccessError)
   case generateEnumFailed(error: StringsEnumError)
}

If I used throws instead of Result in all my APIs, on the throwing end I would need to:

  1. Ensure I don't miss documenting my error type for each function.
  2. Ensure I don't miss wrapping any 3rd-party errors into my custom error types.

With throws instead of Result, on the call site I would need to:

  1. Look up which error type is documented for each throwing function I call.
  2. Provide a catch-all for errors I forgot to wrap into my custom error type (see #2 above).

The throws code would then look like this (note that I'm returning an Effect in TCA):

// func start() -> throws
let baseDirUrl: URL
do {
   baseDirUrl = try baseDirAccess.start()
} catch let unwrappedError as AppSandboxAccessError {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(error: unwrappedError)))
} catch {
   return .init(value: .errorOccurred(error: .unexpectedError(error: error)))
}

Currently, by using Result I don't have all the error-prone burden from above on the developer and my code looks like this instead:

// func start() -> Result<Void, AppSandboxAccessError>
let baseDirUrlResult = baseDirAccess.start()
guard let baseDirUrl = baseDirUrlResult.successValue else {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(baseDirUrlResult.failureError!)))
}

With my proposal, it would look like this and fix also the extra baseDirUrlResult variable and the force-unwrapping (!):

// func start() -> Result<Void, AppSandboxAccessError>
guard let baseDirUrl = baseDirAccess.start() catch {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(error)))
}

I hope that was somewhat clear. I'm open to elaborate more if you have more detailed questions.

6 Likes