Before I let myself touch Swift 6 concurrency, I decided to go back to basics. And it turns out "basics" goes back further than I thought — because before you can write a single line of async code, you need to be fluent in four keywords I'd somehow avoided for most of my career: do, try, catch, and throws.

Here's the thing nobody tells you: outside of async/await, you barely need these. You can ship a whole app and never write throws once. But the second you start doing real asynchronous work, this syntax is everywhere. So this is me paying down the debt.

What clicked for me wasn't how to use them. It was why they exist. So let's build up to them the same way the language did.

Start with the happy path

Say you've got a manager class that hands back a title:

class DataManager {
    func getTitle() -> String {
        return "NEW TEXT"
    }
}

You call it, you set your text, everyone's happy:

let newTitle = manager.getTitle()
self.text = newTitle

Perfect. Right up until it isn't.

What happens when it fails?

Real fetches fail. So your first instinct is to make the return optional and hand back nil when something goes wrong:

func getTitle() -> String? {
    if isActive {
        return "NEW TEXT"
    } else {
        return nil
    }
}
if let newTitle = manager.getTitle() {
    self.text = newTitle
}

This works, but it's a trap. You know that nil means "the fetch failed." Anyone else reading this code — or you, three months from now — sees a screen that just doesn't update and assumes it's a bug. nil tells you something went wrong. It doesn't tell you what.

So return the error too?

Next move: hand back a tuple with both the title and the error.

func getTitle2() -> (title: String?, error: Error?) {
    if isActive {
        return ("NEW TEXT", nil)
    } else {
        return (nil, URLError(.badURL))
    }
}
let returnedValue = manager.getTitle2()
if let newTitle = returnedValue.title {
    self.text = newTitle
} else if let error = returnedValue.error {
    self.text = error.localizedDescription
}

Better — now you can actually surface the error. But this is genuinely annoying to work with. The function says you might get a title and you might get an error, which technically means you could get both, or neither. That's nonsense. A fetch either succeeds or it fails. The type should say that.

Result says exactly that

Result is a generic type that holds one or the other — never both:

func getTitle2() -> Result<String, Error> {
    if isActive {
        return .success("NEW TEXT")
    } else {
        return .failure(URLError(.badServerResponse))
    }
}
let result = manager.getTitle2()
switch result {
case .success(let newTitle):
    self.text = newTitle
case .failure(let error):
    self.text = error.localizedDescription
}

If you've used Combine, this is the same success/failure you've been switching on for years. The intent is now unmissable: one path or the other. This is a real improvement.

But we can still do better. Every single call site has to switch. Wouldn't it be nicer if the function just gave you the string, or blew up trying?

throws: just give me the value, or throw

That's the whole point of throws. Mark the function, and instead of wrapping the outcome in a type, you either return the string or throw an error out of the function:

func getTitle3() throws -> String {
    if isActive {
        return "NEW TEXT"
    } else {
        throw URLError(.badURL)
    }
}

Read it out loud: a function that tries to return a String, but if it fails, it throws an error. No optional, no tuple, no Result to unpack. Clean.

try and do/catch: calling it

Now call getTitle3() and the compiler immediately yells at you — the call can throw but isn't marked with try, and the error isn't handled. Annoying the first time, but it's literally the compiler writing your code for you.

You mark the call with try, and you give the errors somewhere to land with do/catch:

do {
    let newTitle = try manager.getTitle3()
    self.text = newTitle
} catch {
    self.text = error.localizedDescription
}

Small thing worth knowing: you don't have to name the error. Drop the let error and Swift hands you an implicit error constant inside the catch for free.

One failure kills the whole do block

This is the part that bit me, so pay attention. You can stack as many try calls as you want inside a single do:

do {
    let newTitle = try manager.getTitle3()
    self.text = newTitle

    let finalTitle = try manager.getTitle4()
    self.text = finalTitle
} catch {
    self.text = error.localizedDescription
}

But the moment any try throws, you bail straight to catch. If getTitle3() fails, getTitle4() never runs — even if it would have succeeded. Everything after the failing line is dead. Worth keeping in mind when you're wondering why half your block didn't execute.

try? when you don't care about the error

Sometimes you genuinely don't care why it failed — you just want the value or nothing. That's try?. It turns the result into an optional and swallows the error:

let newTitle = try? manager.getTitle3()
if let newTitle {
    self.text = newTitle
}

The nice part: no do/catch needed. Even if getTitle3() throws, try? quietly hands you nil and moves on — the error never escapes the line. Great for third-party APIs that throw things you have no intention of showing the user.

try! exists. Don't use it.

You can also force it:

let newTitle = try! manager.getTitle3()

Now newTitle is a non-optional String, 100% of the time — and your app crashes the instant it throws. It's the error-handling equivalent of a force-unwrap. I'm only mentioning it so you recognize it in the wild. Don't write it.

Why this matters

We didn't write a single line of async or await here, and that's the point. Almost all of your asynchronous code is going to live in functions that throw. When you use throws, you're almost always reaching for do/catch to handle it. So this syntax — the stuff I'd been dodging for years — turns out to be the foundation everything else is built on.

Next up: actually getting into async/await. Now that the error-handling plumbing makes sense, it should go down a lot easier.