Skip to content

How to use Do, Try, Catch, and Throws in Swift | Modern Swift Concurrency #1

Without Swift's error-handling model, every function that can fail must either return an optional, a tuple of (value?, error?), or a Result type — and the caller must manually check each one, making it easy to silently ignore failures. throws, try, do, and catch make failure a first-class part of a function's contract that the compiler forces callers to handle.

What You'll Learn

  • The four approaches to returning fallible data in Swift: (value?, error?) tuples, Result<T, Error>, throws, and try?/try!
  • Why throws is the idiomatic Swift approach and how it keeps the happy path readable while making errors impossible to ignore
  • How do-catch executes a sequence of throwing calls and routes execution to the error handler the moment any call fails

Mental Model

Think of a throwing function as a vending machine with a receipt. You put in your request (try) and one of two things happens: the machine dispenses the item (normal return) or it prints an error receipt and rejects the transaction (throw). The do block is you standing in front of the machine ready to receive. The catch block is what you do when a receipt comes out instead of a snack — you don't just ignore the receipt and walk away.

The earlier approaches (tuple returns, Result) are like a vending machine that always dispenses something but sometimes gives you an empty wrapper and a sticky note that says "it broke." You have to remember to check every sticky note. throws removes the sticky note entirely: the machine is either working or it physically stops you in your tracks.

Detailed Explanation

Swift's error-handling system was designed to make the success path of code as clean as possible while making failure paths explicit and unavoidable. Before throws, there was no language-level mechanism for this. Functions returned optionals or two-value tuples, and forgetting to check the error component compiled without a warning. This is exactly how bugs like "silently using nil image data" end up in shipping apps.

The Result<Success, Failure> type (introduced in Swift 5.0) was a major improvement. It makes the two outcomes — .success and .failure — explicit in the type system, and you must handle both in a switch. However, it forces a somewhat verbose pattern at the callsite (the switch block) and requires threading the Result type through your entire call stack if multiple layers can fail.

throws is cleaner for sequential chains of fallible operations because it short-circuits automatically. Inside a do block, the moment a try expression throws, execution jumps immediately to the catch block — subsequent lines in the do block are skipped. This means you can write a linear sequence of try calls without wrapping each one in its own switch or if let.

The key distinction between try, try?, and try! is about who handles the error and when:

  • try propagates the error up to the nearest enclosing do-catch or to the caller if the current function also throws
  • try? converts a thrown error into nil, silencing it — useful when failure is truly a recoverable "not found" case, dangerous when used to hide real errors
  • try! crashes at runtime if the function throws — only appropriate when a failure would represent a programming error that can never happen (e.g., a hardcoded URL literal)

In the context of async/await (covered in lesson 3), these keywords combine naturally: try await someAsyncThrowingFunction() both suspends for the async result and propagates any error thrown, all in one expression.

Code Structure

DoCatchTryThrowsBootcamp.swift uses a data manager class (DoCatchTryThrowsBootcampDataManager) with four versions of a function that returns a title string, each demonstrating a different error-returning pattern. The commented-out sections in the view model's fetchTitle() method walk through all four approaches in order, so you can uncomment each one to see how the callsite changes. The view renders the resulting string in a tappable blue square.

Complete Code

DoCatchTryThrowsBootcamp.swift

swift
import SwiftUI

// do-catch
// try
// throws

class DoCatchTryThrowsBootcampDataManager {
    
    let isActive: Bool = true
    
    // Approach 1: tuple — caller must manually check both fields; easy to skip the error
    func getTitle() -> (title: String?, error: Error?) {
        if isActive {
            return ("NEW TEXT!", nil)
        } else {
            return (nil, URLError(.badURL))
        }
    }
    
    // Approach 2: Result — type-safe, compiler enforces handling both cases in a switch
    func getTitle2() -> Result<String, Error> {
        if isActive {
            return .success("NEW TEXT!")
        } else {
            return .failure(URLError(.appTransportSecurityRequiresSecureConnection))
        }
    }
    
    // Approach 3: throws — always throws so the caller must use do-catch; isActive is commented out to force the error
    func getTitle3() throws -> String {
//        if isActive {
//            return "NEW TEXT!"
//        } else {
            throw URLError(.badServerResponse) // always throws here — used to demonstrate do-catch error handling path
//        }
    }
    
    // Approach 4: throws — succeeds when isActive is true, throws when false; the idiomatic Swift pattern
    func getTitle4() throws -> String {
        if isActive {
            return "FINAL TEXT!"
        } else {
            throw URLError(.badServerResponse) // failure is an explicit branch, not a hidden optional
        }
    }
    
}

class DoCatchTryThrowsBootcampViewModel: ObservableObject {
    
    @Published var text: String = "Starting text."
    let manager = DoCatchTryThrowsBootcampDataManager()
    
    func fetchTitle() {
        /*
        let returnedValue = manager.getTitle()
        
        // Nothing stops you from ignoring returnedValue.error entirely — the compiler won't warn you
        if let newTitle = returnedValue.title {
            self.text = newTitle
        } else if let error = returnedValue.error {
            self.text = error.localizedDescription
        }
         */
        /*
        let result = manager.getTitle2()
        
        // Result forces you to handle both cases, but every callsite needs its own switch
        switch result {
        case .success(let newTitle):
            self.text = newTitle
        case .failure(let error):
            self.text = error.localizedDescription
        }
        */
        
        
//        let newTitle = try! manager.getTitle3()  // crashes immediately — getTitle3 always throws
//        self.text = newTitle

        do {
            let newTitle = try? manager.getTitle3() // silences the error; newTitle is nil when getTitle3 throws
            if let newTitle = newTitle {
                self.text = newTitle
            }

            let finalTitle = try manager.getTitle4() // propagates throw; execution jumps to catch if this fails
            self.text = finalTitle
        } catch {
            self.text = error.localizedDescription // error is the thrown URLError, automatically bound here
        }
    }
}

struct DoCatchTryThrowsBootcamp: View {
    
    @StateObject private var viewModel = DoCatchTryThrowsBootcampViewModel()
    
    var body: some View {
        Text(viewModel.text)
            .frame(width: 300, height: 300)
            .background(Color.blue)
            .onTapGesture {
                viewModel.fetchTitle() // triggers the error-handling chain on each tap
            }
    }
}

struct DoCatchTryThrowsBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        DoCatchTryThrowsBootcamp()
    }
}

Code Walkthrough

  1. Four versions of getTitle — Each version represents an evolution of Swift error-handling idioms. Start by reading all four signatures before looking at the view model. The pattern you choose in the data layer determines how verbose and error-prone the callsite becomes.

  2. getTitle() returning a tuple — This is the pre-Swift error-handling pattern. The compiler treats (title: String?, error: Error?) as a plain value; you can ignore the error field entirely and it will compile fine. This is the most dangerous pattern.

  3. getTitle2() returning Result — The switch forces you to acknowledge both outcomes. This is a good pattern when you want to pass the success-or-failure value around before handling it, because Result is just a value type you can store, transform, or map over.

  4. try? manager.getTitle3()try? converts the thrown error into nil. Since getTitle3() always throws in this example, newTitle will always be nil here, and the if let block is never reached. This is deliberate: it shows that try? silently swallows errors, so if you needed to log or display the error, you would have lost it.

  5. try manager.getTitle4() inside do-catch — This is the idiomatic approach. If getTitle4() throws, execution exits the do block immediately and enters catch. The error constant is automatically bound to whatever Error was thrown — you don't need to unwrap anything. Since isActive is true, this succeeds and sets self.text = "FINAL TEXT!".

  6. Execution flow on tap — When the user taps, fetchTitle() runs: try? silently fails for getTitle3, then try succeeds for getTitle4 (because isActive is true), so the displayed text becomes "FINAL TEXT!". Toggle isActive to false to see the catch branch execute.

Common Mistakes

Mistake: Using try! on anything that can fail at runtimetry! is a force-unwrap for errors. If the function throws, the app crashes immediately with no recovery path. It's only acceptable when failure represents a programming error that literally cannot happen (e.g., try! JSONDecoder().decode(MyType.self, from: hardcodedValidData)). Never use it on network calls, file I/O, or any data you don't fully control.

Mistake: Using try? everywhere to avoid writing do-catchtry? converts any thrown error into nil. This is fine for "is this parseable?" checks, but if you use it on a network or database call, you're silently discarding the error — the user sees nothing, your logs capture nothing, and you have no idea if the failure was a timeout, a 401, or corrupt data. Always decide consciously whether you need the error information.

Mistake: Catching errors and displaying error.localizedDescription directly to userslocalizedDescription on URLError returns strings like "The operation couldn't be completed. (NSURLErrorDomain error -1009.)" — this is meaningless to a user. In production code, catch specific error types with catch let urlError as URLError and map them to user-friendly messages like "No internet connection. Please check your network settings."

Key Takeaways

  • throws makes failure a part of the function's contract that the compiler enforces at every callsite — it's impossible to accidentally ignore an error the way you can with tuple returns
  • do-catch short-circuits: the moment any try expression inside a do block throws, execution jumps to catch and all subsequent lines in do are skipped
  • Reserve try! for genuinely impossible failures and try? for cases where nil is a meaningful, expected result — not as shortcuts to avoid writing error handling

Last updated: June 27, 2026

Released under the MIT License.