Overview

Use an optional when a value can be legitimately absent and a thrown error when an operation failed for a reason the caller may need. Internal code lets errors propagate; the function that represents a user action or network event catches and translates them. Read swift for the rest of the language defaults.

Never force-unwrap in shipping code

! and try! crash with an unhelpful message. Use guard let, if let, or ??.

guard let first = users.first else { return [] }
let name = profile.displayName ?? "Anonymous"

The narrow exceptions are IBOutlet properties, resources compiled into the bundle (an asset catalog image), and test fixtures where a missing value should fail loudly. When a nil is a programmer error, state it: guard let x else { preconditionFailure("x set in init") } documents the invariant where a bare ! does not.

Exit early with guard let

Put the failure exit at the top so the happy path stays linear and the binding stays in scope for the rest of the function. Use if let when both branches are meaningful. Since Swift 5.7 write if let userId { ... } instead of repeating the name. Chain ?? at most twice; a longer chain means the model is wrong.

Do not let optional chaining hide a failure

profile?.address?.postalCode returns nil for any missing link and tells you nothing about which. If nil at some step is an error, unwrap and throw.

guard let address = profile.address else { throw ProfileError.missingAddress }

map and flatMap on an optional transform without unwrapping: username.map { $0.uppercased() }.

Prefer throws when the caller needs the reason

func parseDate(_ s: String) -> Date? says nothing about why it failed; throws -> Date lets the caller distinguish empty input from a bad format. Use try? only when the reason is irrelevant and nil means “skip”, and try! only in tests or startup code where failure is a bug.

Group errors by domain, not by operation

One error enum per subsystem keeps switch statements short. Name it for the domain, and have each subsystem’s entry point translate lower-level errors into its own type.

enum PersistenceError: Error {
    case network(URLError)
    case decoding(DecodingError)
    case diskFull
}

Use typed throws only for closed error sets

Swift 6.0 added throws(ErrorType) (SE-0413). Untyped throws remains the better default. Reserve typed throws for code inside a module where you always handle the error, generic code that only forwards a closure’s error, and constrained environments such as Embedded Swift. Exposing a typed error from a public API commits you to that set forever.

func load(key: String) throws(CacheError) -> Data { ... }
 
do throws(CacheError) {
    process(try load(key: "profile"))
} catch .miss(let key) {
    refetch(key)
} catch .expired, .corrupted {
    invalidate()
}

Use Result to store an outcome

Result<Value, Failure> fits where the outcome must be held as a value: caching a completed call or crossing a callback API that cannot become async. In async code, async throws is cleaner. Mark a function rethrows when it only propagates its closure’s error, so callers passing a non-throwing closure skip try.

Catch at the boundary and map to UI state

@MainActor
func loadProfile(id: UUID) async {
    do { profile = try await fetchUser(id: id) }
    catch is URLError { errorMessage = "Check your connection." }
    catch { errorMessage = "Something went wrong."; logger.error("\(error)") }
}

Never catch CancellationError just to show an alert; a cancelled task is not a failure (swift-concurrency).