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 singleMyLibraryError
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 writingwhere error.code ==
. - Optional Property: In the above snippet, the invalid character property
should only be non-optional for theinvalidCharacter
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:
- 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. - 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?