Preferred Patterns for public error APIs

While Swift errors can be defined using enums, the lack of support for
non-frozen enums presents challenges (context: non-frozen pitch,
extensible pitch. Adding a case to a public error API that uses a
non-frozen enum results in a breaking change, making them less than ideal.

An optimal public error API should meet the following criteria:

  • Extensibility without breaking changes
  • Fluent usage when catching errors
  • Ability to associate additional data (via property or associated value)
  • Capacity to serve as a namespace for related errors

Firebase is exploring ways to achieve this using the latest version of Swift,
and we'd like to share an approach we are considering.

Using structs to represent sub-errors

public struct JSONParsingError: Error {
  public struct Code: Equatable, Sendable {
    public static let invalidCharacter = Code(code: 1)
    public static let mismatchedBracket = Code(code: 2)
    public static let internalError = Code(code: 3)
    private let code: Int
  }

  public let code: Code
  
  // These two properties will be populated for all codes.
  public let line: Int
  public let column: Int
  
  // If the error code is `invalidCharacter`, this will
  // be the invalid character; otherwise, nil.
  // Unfortunately, there is no way to make this property's
  // optionality dependent on the code.
  public let invalidCharacter: Character?
}

// MARK: - Usage

func parse(_ source: String) throws -> [String: Any] {
  // ...
  throw JSONParsingError(code: .mismatchedBracket, line: 19, column: 5, invalidCharacter: nil)
  // ...
}

do {
  try parse("{]")
} catch let error as JSONParsingError where error.code == .mismatchedBracket {
  // ...
} catch let error as JSONParsingError where error.code == .invalidCharacter {
  // Catch specific sub-error and access sub-error specific property.
  if let invalidCharacter = error.invalidCharacter {
    // ...
  }
} catch let error as JSONParsingError {
  // Catch typed error and switch over it's code.
  switch error.code {
  case .internalError: break
  case .invalidCharacter: break
  case .mismatchedBracket: break
  default: break // Compiler enforces that switch must be exhaustive.
  }
} catch {
  // ...
}

Pros:

  • Backwards Compatibility: Adding sub-error codes to individual error
    structs doesn't break existing code.
  • Fluent Usage: This approach allows catching the error type itself or its
    specific cases using the where clause: catch let error as X where error.code == .invalidCharacter.
  • Namespaced Errors: Related errors are grouped within a common namespace
    (JSONParsingError) rather than a single MyLibraryError type. This enables
    throwing specific error namespaces instead of broad errors.
  • Future Compatibility: If non-frozen Swift enums are supported, customers
    will only need to add the @unknown prefix to their default case.

Cons:

  • Incomplete autocomplete support: There is no autocomplete option for the
    entire switch statement. However, there is an autocomplete suggestion when
    declaring each case and there is autocomplete when writing where error.code ==.
  • Optional Property: In the above snippet, the invalid character property
    should only be non-optional for the invalidCharacter code. Since we aren't
    using enums, we cannot use their associated values and are left with leaving
    the property as optional to account for the other codes where there is no
    invalid character to report. If a code has multiple unique associated
    properties, it should be refactored out into its own error struct (e.g.
    InvalidJSONCharacterParsingError).

Some other approaches we considered were:

  1. Defining a sub-error enum in C, so it could be imported as non-frozen. This
    has autocomplete support for the populating the entire switch statement, but
    introduces packaging complexity by introducing a C target.
  2. Creating an error struct for each error case. This quickly leads to the
    creation of many public API error structs.

What are your thoughts on this pattern? Do you have any suggestions or
alternative patterns that you've found effective?

5 Likes