JSTAcademy
0 XP
Dashboard
Crash Courses
Error Handling & the Result Type
20 min
PhD+185 XP
Crash Courses · PhD

Error Handling & the Result Type

Handle errors the Swift way with throwing functions, do/try/catch, defer, and the Result<Success, Failure> type for explicit error propagation.
20 min read+185 XP on completionCert: Swift Crash Course
Tap any word in the text below to start reading from there.

Error Handling & the Result Type

Defining Error Types

// Swift errors conform to the Error protocol (usually enums)
enum ValidationError: Error, LocalizedError {
    case emptyField(fieldName: String)
    case tooShort(fieldName: String, minimum: Int, actual: Int)
    case invalidEmail(String)
    case outOfRange(value: Double, min: Double, max: Double)

    var errorDescription: String? {
        switch self {
        case .emptyField(let name):
            return "\(name) cannot be empty"
        case .tooShort(let name, let min, let actual):
            return "\(name) must be at least \(min) characters (got \(actual))"
        case .invalidEmail(let email):
            return "'\(email)' is not a valid email address"
        case .outOfRange(let value, let min, let max):
            return "\(value) must be between \(min) and \(max)"
        }
    }
}

---

Throwing Functions

func validateEmail(_ email: String) throws -> String {
    guard !email.isEmpty else {
        throw ValidationError.emptyField(fieldName: "Email")
    }
    guard email.contains("@") && email.contains(".") else {
        throw ValidationError.invalidEmail(email)
    }
    return email.lowercased().trimmingCharacters(in: .whitespaces)
}

func validatePassword(_ password: String) throws -> String {
    guard !password.isEmpty else {
        throw ValidationError.emptyField(fieldName: "Password")
    }
    guard password.count >= 8 else {
        throw ValidationError.tooShort(fieldName: "Password", minimum: 8, actual: password.count)
    }
    return password
}

// Calling throwing functions
do {
    let email    = try validateEmail("jordan@jst.com")
    let password = try validatePassword("secure123")
    print("Valid: \(email), password length: \(password.count)")
} catch ValidationError.emptyField(let name) {
    print("Empty: \(name)")
} catch ValidationError.invalidEmail(let e) {
    print("Bad email: \(e)")
} catch ValidationError.tooShort(let name, let min, let actual) {
    print("\(name) too short: \(actual) < \(min)")
} catch {
    print("Unexpected error: \(error)")  // catch-all
}

---

try?, try!, and rethrows

// try? — returns Optional, nil on error
let validEmail   = try? validateEmail("alice@example.com")  // Optional("alice@example.com")
let invalidEmail = try? validateEmail("notanemail")          // nil

// Combine with ?? for defaults
let email = (try? validateEmail(userInput)) ?? "unknown@email.com"

// try! — only use when error is truly impossible
// e.g. a hardcoded regex pattern that you KNOW is valid
let emailRegex = try! NSRegularExpression(pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")

// rethrows — for higher-order functions that throw only if the closure throws
func transform<T, U>(_ value: T, using fn: (T) throws -> U) rethrows -> U {
    return try fn(value)
}
let result = try transform("jordan@jst.com", using: validateEmail)

---

defer

func processFile(named filename: String) throws -> String {
    print("Opening \(filename)")
    defer {
        print("Closing \(filename)")  // always runs when function exits
    }

    guard filename.hasSuffix(".txt") else {
        throw ValidationError.invalidEmail(filename)  // reusing for demo
    }

    // ... process the file ...
    return "Processed: \(filename)"
}

// Even when an error is thrown, defer runs:
do {
    let result = try processFile(named: "data.csv")
    print(result)
} catch {
    print("Error: \(error)")
}
// Output:
// Opening data.csv
// Closing data.csv  ← defer ran even though error was thrown
// Error: ...

---

Result<Success, Failure>

// Use Result when you want to return success/failure without throwing
// Great for async callbacks and when you want to store the result

enum NetworkError: Error {
    case noConnection
    case timeout
    case invalidResponse(statusCode: Int)
    case decodingFailed(Error)
}

func fetchUser(id: Int, completion: @escaping (Result<User, NetworkError>) -> Void) {
    // Simulate network call
    if id <= 0 {
        completion(.failure(.invalidResponse(statusCode: 400)))
        return
    }
    let user = User(id: String(id), name: "User \(id)")
    completion(.success(user))
}

fetchUser(id: 1) { result in
    switch result {
    case .success(let user):
        print("Found: \(user.name)")
    case .failure(let error):
        print("Error: \(error)")
    }
}

// Result with map/flatMap for chaining
let rawInput = " jordan@jst.com "
let validated: Result<String, ValidationError> = Result {
    try validateEmail(rawInput)
}

let upper = validated.map { $0.uppercased() }
switch upper {
case .success(let email): print("Validated: \(email)")
case .failure(let error): print("Failed: \(error.errorDescription ?? "")")
}

// get() throws for interop
let email2 = try? validated.get()  // returns Optional<String>
0%