Typed throws in Swift 6
Table of Contents
Swift's throws has never told us what a function throws.
struct Cat {}
enum CatError: Error {
case sleeps
case sitsAtATree
}
func callCat() throws -> Cat
So the catch gets any Error, and we have to cast our way back to the type we already knew it would be.
do {
let cat = try callCat()
} catch let error as CatError {
// handle it
} catch {
// ...and a branch for errors that can never actually happen
}
SE-0413, introduced in Swift 6.0, lets the function say so.
func callCat() throws(CatError) -> Cat
The syntax
The thrown type goes in parentheses after throws.
func callCat() throws(CatError) -> Cat {
if Int.random(in: 0..<24) < 20 {
throw .sleeps
}
return Cat()
}
Note throw .sleeps rather than throw CatError.sleeps. Because the thrown type is now part of the signature, every throw in the body has contextual type information, and leading-dot syntax works the same way it does for a function argument.
Two spellings are special cases of things we already have:
func a() throws(Never) { } // same as not throwing at all
func b() throws(any Error) { } // same as plain `throws`
So throws was always typed. It was just always typed as any Error.
You can easily support sarunw.com by checking out this sponsor.
AI Paraphrase:Are you tired of staring at your screen, struggling to rephrase sentences, or trying to find the perfect words for your text?
What it does to catch
Inside a do block, the implicit error takes on the concrete type.
do {
let cat = try callCat()
} catch {
// error is CatError, not any Error
switch error {
case .sleeps: feed()
case .sitsAtATree: fetchLadder()
}
}
That switch is exhaustive, and the compiler checks it. This is the real payoff — not the annotation on the function, but the fact that the catch can no longer silently miss a case when someone adds one to the enum.
If a do block calls several functions that throw different types, the inferred type falls back to any Error, as you'd expect. We can also pin it explicitly:
do throws(CatError) {
try callCat()
} catch {
// error is CatError
}
It replaces rethrows
For this section, say we have a function that turns a name into a cat and declares what it throws:
func parse(_ name: String) throws(CatError) -> Cat {
guard name != "" else { throw .sleeps }
return Cat()
}
let names = ["Tom", "Jerry"]
Now look at map. Sometimes the closure we hand it throws, sometimes it doesn't:
let lengths = names.map { $0.count } // no try
let cats = try names.map { try parse($0) } // try
map itself never throws. It only throws when our closure does, and rethrows is the keyword that says exactly that:
func map<U>(_ transform: (Element) throws -> U) rethrows -> [U]
That one word is why lengths needs no try and cats does.
What rethrows cannot do is say which error, because it only tracks throws-or-not. The error still arrives as any Error.
Typed throws covers both at once. Give the closure's error type a name, E, and hand the same E back out:
extension Collection {
func map<U, E: Error>(
_ transform: (Element) throws(E) -> U
) throws(E) -> [U] {
var result: [U] = []
for element in self {
result.append(try transform(element))
}
return result
}
}
A thrown type used as a generic parameter conforms to Error automatically, so <E> works as well as <E: Error>.
Now E follows whatever we pass in:
// Closure doesn't throw, so E is Never.
// throws(Never) means non-throwing, so no try.
let lengths = names.map { $0.count }
// parse throws CatError, so E is CatError.
let cats = try names.map(parse)
rethrows existed because "might throw, might not" had no spelling. throws(Never) is that spelling, so a plain generic function replaces the keyword.
One catch worth knowing
The error type survives only if it is written down somewhere:
let annotated: (String) throws(CatError) -> Cat = { try parse($0) }
try names.map(parse) // catch gets CatError
try names.map(annotated) // catch gets CatError
try names.map { try parse($0) } // catch gets any Error
parse writes the type in its signature and annotated writes it in its type. The closure literal writes nothing, so the compiler infers it — and inference only decides whether a closure throws, never what it throws. It lands on plain throws, which is throws(any Error).
So a trailing closure, which is how most of us call map, gives back any Error — the same thing rethrows gave us. Pass a function reference or an annotated closure when the type matters.
This is not a temporary gap. Inferring a closure's thrown type was left as a future direction in the proposal, and has not shipped.
When not to use it
This is the part worth reading twice, because the feature is more tempting than it is useful, and the proposal itself spends real space arguing against overusing it.
Untyped throws remains the better default for most code.
The problem is API evolution. Suppose we write this:
public func loadBytes(from path: String) throws(FileError) -> [UInt8]
Now we want to support loading from a URL. The network errors have nowhere to go. Widening FileError to any Error is a source-breaking change for every caller that wrote an exhaustive switch, which is exactly the thing we sold them on. We have made an implementation detail — which errors this function currently happens to produce — part of the public contract.
Untyped throws is vague on purpose, and that is what lets the implementation change later.
The proposal names three cases where typed throws does pay off:
- Code inside a single module, where exhaustive handling is genuinely wanted and there is no cross-module compatibility to preserve. Refactoring both sides at once is cheap.
- Generic functions that only rethrow from a closure, as in
mapabove. Here the error type is passed through rather than declared, so no contract is being pinned down. - Embedded or dependency-free code, where
any Errorboxing has a runtime cost that matters.
Outside of those, the honest answer is usually that any Error was fine.
You can easily support sarunw.com by checking out this sponsor.
AI Paraphrase:Are you tired of staring at your screen, struggling to rephrase sentences, or trying to find the perfect words for your text?
Summary
Swift 6 lets a function declare its thrown type with throws(SomeError). The catch block then gets that concrete type, which makes exhaustive error handling checkable.
throws(Never) means non-throwing, throws(any Error) means plain throws, and a generic thrown type replaces rethrows — though only when the closure's error type is written down, which a trailing closure never does.
But the annotation is a promise about your errors, and promises are hard to take back. Reach for it inside a module or in pure pass-through generic code, and leave public API boundaries untyped unless you are certain the error set will never grow.
Read more article about Swift, Swift Evolution, Error Handling, or see all available topic
Enjoy the read?
If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.
If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.
Become a patron Buy me a coffee Tweet ShareTrailing commas beyond arrays in Swift 6.1
Swift 6.1 allows a trailing comma in parameter lists, tuples, generic parameters, capture lists, and more. A small change that makes diffs a lot cleaner.