TL;DR
It seems that we need enum conversion operations for more comfortable explicit error handling. Something like
Merging UserError
with AnimalError
enum UserError: Swift.Error {
case firstName
case lastName(detailedError: String)
}
enum AnimalError: Swift.Error {
case dogError(DogError)
case hamsterError(HamsterError)
}
typealias UserNameAndAnimalError = UserError | AnimalError
which results in a type:
enum UserNameAndAnimalError: Swift.Error {
case dogError(DogError)
case hamsterError(HamsterError)
case firstName(FirstNameError)
case lastName(LastNameError)
}
Narrowing AnimalError
typealias HamsterAnimalError = AnimalError / .dogError
which results in a type:
enum HamsterAnimalError: Swift.Error {
case hamsterError(HamsterError)
}
But I'm open to all suggestion and ideas. But for this you need to read the long version.
The long version
Working on a lot of Swift code as an app developer for some years now I ofen felt that the language is not explicit enough about errors. Since reading the Error Handling Rationale and Proposal and using throws
in Swift, I asked myself if this approach is explicit enough for my own modeling needs. For a language that tries to make almost every bit transformation as explicit as possible (see Double.init(exactly:)) it looks unbalanced to me omitting error information from types. At least we can see the existence of errors via throws
and try
. For async operations and more explicit error handling it was clear to me that we need a Result<T, E>
type. The question was more concentrated on what is the defacto Result
type on GitHub. In the end I ended up building my own on every project. So it's nice to see the (explicit error)
Result
type beeing part of the standard library since Swift 5 (Add Result to the Standard Library).
But using explicit errors with Result
has major implications for a code base. Because the exception handling mechanism ("goto catch") is not built into the language (like throws), you need to do that on your own in a "Result chaining" (i.e. monadic) way with flatMap
and similar operators, if you don't want to unwrap/switch/wrap on every chaining/mapping point. Leading to code like this:
struct GenericError: Swift.Error {
let message: String
}
struct User {
let firstName: String
let lastName: String
}
func stringResultFromArray(_ array: [String], at index: Int, errorMessage: String) -> Result<String, GenericError> {
guard array.indices.contains(index) else { return Result.failure(GenericError(message: errorMessage)) }
return Result.success(array[index])
}
// no problem with converting errors, because it is always `GenericError`
func userResultFromStrings(strings: [String]) -> Result<User, GenericError> {
return stringResultFromArray(strings, at: 0, errorMessage: "Missing first name")
.flatMap { firstName in
stringResultFromArray(strings, at: 1, errorMessage: "Missing last name")
.flatMap { lastName in
return Result.success(User(firstName: firstName, lastName: lastName))
}
}
}
For a long time I thought this is to burdensome and we need to skip that and just try to handle the correct errors somehow. Which is not an issue if you just want to print out "Hey user, something went wrong (with you or your internet connection)". If you want to combine async operations and Result it gets even harder, but frameworks like ReactiveSwift or Combine can help us out with these (but not RxSwift as it does not provide an error type variable in its Observable type).
In the end you always end up with chaining/mapping successful/error values and its corresponding types (as long as language support is missing).
Simple Example:
struct FirstNameError: Swift.Error {
}
func firstNameResultFromArray(_ array: [String]) -> Result<String, FirstNameError> {
guard array.indices.contains(0) else { return Result.failure(FirstNameError()) }
return Result.success(array[0])
}
func userResultFromStrings(strings: [String]) -> Result<User, GenericError> {
return firstNameResultFromArray(strings)
.map { User(firstName: $0, lastName: "") }
.mapError { _ in
// Mapping from `FirstNameError` to a `GenericError`
GenericError(message: "First name is missing")
}
}
So I'm interested in the most comfortable way to handle (error type) conversions necessary to propagate an error through the call stack.
For the last months I worked on a TypeScript/GraphQL project which introduced me to structural typing which is different from Swift's nominal typing. We started out with exceptions because they are a language feature and I know it would be burdensome to introduce Result
or something like async Result
with explicit error types. But we finally reached a point where bugs started to appear because we did not oversee where errors can be thrown (missing throws
/try
in TypeScript) or which errors can be thrown. So we started to introduce all this Result
chaining stuff which is not an easy step to do, but I came out with a solution that encodes errors as types explicitly and also is quite comfortable at converting error types.
Example:
function identity<A>(arg: A): A {
return arg;
}
class CatError extends Error {
readonly name = "CatError";
}
class DogError extends Error {
readonly name = "DogError";
}
class HamsterError extends Error {
readonly name = "HamsterError";
}
// a union of erros, discriminated by `name`
type AnimalError = CatError | DogError | HamsterError;
// because cats never really fail
function catErrorAsUndefinedResultForAnimalError(
error: AnimalError
): Result<undefined, DogError | HamsterError> {
return error instanceof CatError // <- type of `error` is narrowed automatically from AnimalError to DogError | HamsterError
? Result.success(undefined)
: Result.failure(error);
}
function noCatErrorResultForAnimalResult(
animalResult: Result<string, AnimalError>
): Result<string | undefined, DogError | HamsterError> {
return animalResult
.map<string | undefined>(identity) // allow undefined in Success
.flatMapFailure(catErrorAsUndefinedResultForAnimalError);
}
The combination of discriminated unions of errors (with auto union merging through structural typing) and automatic type narrowing leads to quite simple error conversions.
Now my question: How can we accomplish comfortable error type conversions in Swift?
Some ideas:
First step: Stop using enum cases for specific errors
The problem with an enum case in this context is that has no distinct type. It just has the type of the whole enum. If we want to convert a specific error case into a successful value (comparable to catching it), the resulting error type ist not able to represent the now missing error case:
enum UserError {
case firstName
case lastName(detailedError: String)
}
// does not compile
func omitLastNameError(userError: UserError) -> UserError.firstName? {
switch userError {
case .firstName:
return userError
case .lastName:
return nil
}
}
But how do we group/abstract errors then in Swift?
Second step: Use enums to group specific errors
Because Swift will not have type constraints like DogError | HamsterError
(see Commonly Rejected Changes > Miscellaneous), I could not think of another way than using enums for this.
struct FirstNameError: Swift.Error {
}
struct LastNameError: Swift.Error {
let detailedMessage: String
}
enum UserError: Swift.Error {
case firstName(FirstNameError)
case lastName(LastNameError)
}
What's missing:
We are missing auto generation / merging / narrowing of these enums. This leads to new manual type definitions for many error conversion points, which makes it almost unmanageable.
Assume this error:
enum AnimalError: Swift.Error {
case dogError(DogError)
case hamsterError(HamsterError)
}
Would be nice, if we can do this: Merging UserError
with AnimalError
typealias UserNameAndAnimalError = UserError | AnimalError
which results in a type:
enum UserNameAndAnimalError: Swift.Error {
case dogError(DogError)
case hamsterError(HamsterError)
case firstName(FirstNameError)
case lastName(LastNameError)
}
Would be nice, if we can do this: Narrowing AnimalError
typealias HamsterAnimalError = AnimalError / .dogError
which results in a type:
enum AnimalError: Swift.Error {
case hamsterError(HamsterError)
}
Furthermore type narrowing while checking types would be really useful for this (by the way for all if let
, guard let
dances).
So. Do we really need this? What are your ideas? I'm curious.