How do you design the error type of a library: stable cases, underlying errors, LocalizedError, and bridging to NSError?

15.10.3 · L5 · Interview

Predict first

What do the description, domain, code and underlying error look like for the two cases, and for an error that conforms to nothing?

import Foundation
struct Plain: Error {}
enum PaymentError: Error {
    case declined(code: Int)
    case network(underlying: any Error)
}
extension PaymentError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .declined: "The card was declined."
        case .network: "The payment could not reach the server."
        }
    }
    var failureReason: String? { if case .network(let e) = self { (e as NSError).localizedDescription } else { nil } }
}
extension PaymentError: CustomNSError {
    static var errorDomain: String { "com.example.payments" }
    var errorCode: Int { if case .declined(let c) = self { c } else { -1 } }
    var errorUserInfo: [String: Any] { if case .network(let e) = self { [NSUnderlyingErrorKey: e] } else { [:] } }
}
let d = PaymentError.declined(code: 51)
print(d.localizedDescription)
let ns = d as NSError
print(ns.domain, ns.code)
let n = PaymentError.network(underlying: URLError(.notConnectedToInternet))
let nn = n as NSError
print(nn.localizedDescription, "|", nn.localizedFailureReason ?? "-", "|", (nn.userInfo[NSUnderlyingErrorKey] as? NSError)?.code ?? 0)
print("not conforming:", Plain().localizedDescription.hasPrefix("The operation couldn’t be completed."))
  • declined text / domain com.example.payments with code 51 / network text | reason | -1009 / the default text starts with "The operation couldn’t be completed."
  • The domain is the type name
  • localizedDescription is the case name

Mental model. An error type is a form letter template: fixed fields for the machine, a plain-language paragraph for the human, and a reference to the earlier letter that started the problem.

Q358 covers where to translate errors; this is about what the error type promises. A library error is part of the public API, so design it like one. Use an enum with a small set of stable cases that callers will switch over (declined(code:), network(underlying:)), and carry the lower-level error as an associated value instead of flattening it into a string, so callers who care can inspect the URLError and callers who do not can ignore it. Make the enum non-frozen in an evolving library (callers need @unknown default) so adding a case is not a source break. Conform to LocalizedError (errorDescription, failureReason, recoverySuggestion) so that error.localizedDescription is a sentence the user can read: without it, Swift errors print as “The operation couldn’t be completed. (Module.Type error 1.)” as the last line of the check confirms. Do not put user-visible copy in the library if the app localises it; offer machine-readable cases and let the app map them (Q175 covers bridging).

CustomNSError controls the Objective-C and system face of the error: a stable errorDomain (the check uses “com.example.payments” rather than the mangled type name, which would change if you renamed the type), an errorCode that you promise never to renumber, and errorUserInfo including NSUnderlyingErrorKey, which is how crash reporters, analytics and Objective-C callers see the cause chain (the check reads the underlying NSURLErrorDomain code −1009 back). Other conformances worth adding: Equatable or Sendable when payloads allow (errors cross actors), and Hashable only if you need it. Avoid a single catch-all .unknown(String) case as the main path; it hides what callers could handle. Document, per function, which errors it throws; with typed throws (throws(PaymentError)) the compiler enforces it, at the price of making the error type part of the signature forever.

LocalizedError, CustomNSError and NSUnderlyingErrorKey give readable text and a stable machine face.

Common traps

  • Relying on the default NSError domain. It is derived from the type name; renaming the type changes the domain that analytics and callers match on.
  • Flattening the cause into a string. Callers can no longer react to it; keep the underlying error.

They might follow up with

  • Should the library localise messages? Only if it ships its own strings; otherwise expose cases and let the app decide.
  • Typed or untyped throws for a public API? Typed only when the error set is small and stable; otherwise stay with any Error and document.

Related questions

  • 15.10.4 When should an API trap with precondition, return nil, or throw: programmer error versus expected failure?
  • 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…
  • 4.4.3 How do Swift errors and Objective-C NSError translate across the border?
  • 15.5.2 How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus…

← How do noncopyable types and consuming methods make an API hard to…   When should an API trap with precondition, return nil, or throw… →