Question/Idea: Improving explicit error handling in Swift (with enum operations)

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. :wink:

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. :man_shrugging::sweat_smile: 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 :thinking:

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.

2 Likes

Very interesting idea! I'm not sure this is the best solution for throwing, but there are often cases where I want to reuse a set of enum cases in another enum, for example.

Small notation note:

This should probably be something like AnimalError \ [.dogError]? Come to think of it, the union between enums should probably not be written as an or either.

Absolutely. I got set difference wrong. :smiley: That's what I meant. :+1:

Yes I considered this more for the use of errors with Result, not specially for throws. For throws you need more rules what type/structure should be derived from throwing at multiple points in a block of code.

I mean, if we want typed throws we would probably need something like this, I was actually going to write throws TypeAError || TypeBError in my own example, because that's really what it would have to return. But it might get out of hand, and I'm not sure typed throws really add that much - you could always switch over a specific error type inside a catch.

Yes, but look at this in my post

So I think we need to try it with enums.

It was considered some years back. Would be good to know what speaks against this?

First issue I can see is, that we have a namespacing problem, because types from different modules can be named the same.

import DogCore
import CatCore

enum FamilyError {
  case dogCoreAnimalError(DogCore.AnimalError)
  case catCoreAnimalError(CatCore.AnimalError)
}
Terms of Service

Privacy Policy

Cookie Policy