Swift Error Handling and Generics: throws, Result
Swift 6.4.2 intermediate lecture: do/try/catch, a real try! crash, typed throws, Result, generic constraint errors, and generic vs any measured at -O.
Series: Swift lecture 4/6 (intermediate). For developers who can design structs, enums and protocols (lecture 3) and now write code that can fail or that should work for many types. After this lecture you can choose between throws, typed throws, Result, Optional and try!, and you can read the compiler's "requires that X conform to Y" errors without guessing.
Two things slow down Swift developers once their code grows past toy size. Error handling has five spellings (throws, try?, try!, Result, typed throws(E)), and generics produce error messages that seem to blame the wrong line. This lecture is for people who maintain a parser, a network layer or a small library and have hit either problem. Everything was compiled and run with Swift 6.4.2 (swiftc -swift-version 6, Swift 6 language mode with strict concurrency checking) on an Apple Silicon Mac (M1 Pro). The outputs, crash messages and compiler errors below are copied from those runs.
As in lecture 3, remember that plain swift file.swift on this toolchain runs in Swift 5 language mode. Add -swift-version 6.
throws, do/try/catch, try? and try!
A function marked throws can stop and hand an error to its caller instead of returning a value. The caller must write try in front of the call. That keyword is a visible marker that control may leave here. An error is any type that conforms to the Error protocol, and an enum is the usual choice:
enum ParseError: Error, CustomStringConvertible {
case empty
case notANumber(String)
case outOfRange(Int)
var description: String {
switch self {
case .empty: "empty input"
case .notANumber(let s): "not a number: \"\(s)\""
case .outOfRange(let v): "out of range: \(v)"
}
}
}
func parseAge(_ s: String) throws -> Int {
guard !s.isEmpty else { throw ParseError.empty }
guard let v = Int(s) else { throw ParseError.notANumber(s) }
guard (0...150).contains(v) else { throw ParseError.outOfRange(v) }
return v
}
for input in ["42", "", "abc", "200"] {
do {
let age = try parseAge(input)
print("ok:", age)
} catch ParseError.outOfRange(let v) where v > 150 {
print("too old:", v)
} catch {
print("failed:", error, "| type:", type(of: error))
}
}
print("try? :", (try? parseAge("abc")) as Any, (try? parseAge("7")) as Any)
let forced = try! parseAge("x1")
print("unreachable", forced)
ok: 42
failed: empty input | type: ParseError
failed: not a number: "abc" | type: ParseError
too old: 200
try? : nil Optional(7)
errors/errors.swift:30: Fatal error: 'try!' expression unexpectedly raised an error: not a number: "x1"
The process exit code was 133, which is 128 + 5 (SIGTRAP). The terms in this example:
catchclauses are checked from top to bottom, and they can use patterns andwhere, just likeswitch. A final barecatchreceives the error as a constant namederror.try?turns "throws or returnsInt" intoInt?. The error itself is discarded, so you no longer know why it failed.try!says "this cannot fail; crash if it does". In production that is a crash report, not an exception you can catch.
Common mistake → what happens → fix. Our first run of this file redirected stdout to a file. The output file had 0 lines, and only the fatal error appeared on stderr. Every print before the crash was lost, because stdout to a file is buffered and a trap does not flush it. In a CI log or a server process this looks like "the program crashed before doing anything". The fix for debugging is import Foundation and fflush(stdout) before the risky line (that is how we captured the output above). The real fix is not to use try! on input you do not control. Keep it for things like a regex literal or a bundled resource that would be a programmer error to get wrong.
Typed throws (Swift 6): verified
Plain throws means "throws any Error", so a caller's catch sees an existential and needs a fallback branch. Typed throws (SE-0413, Swift 6.0 compiler) lets a function name the one error type it throws:
enum ParseError: Error { case empty, notANumber(String) }
struct NetworkError: Error {}
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty } // type known: .empty works
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
do {
_ = try parse("x")
} catch {
// error is ParseError here, not any Error: the switch needs no default
switch error {
case .empty: print("empty")
case .notANumber(let s): print("typed catch:", s)
}
}
let r = Result { try parse("12") }
print(r, type(of: r))
func never() throws(Never) -> Int { 1 } // same as non-throwing
print(never())
typed catch: x
success(12) Result<Int, Error>
1
It compiled with zero diagnostics under -swift-version 6 and also under -swift-version 5. Typed throws is a compiler feature, not a Swift 6 language-mode feature. Throwing the wrong type is a compile error:
typed_err.swift:4:27: error: thrown expression type 'NetworkError' cannot be converted to error type 'ParseError'
Look at the second output line: Result { try parse("12") } became Result<Int, Error>, not Result<Int, ParseError>. Closures still infer any Error for their thrown type. When we wrote Result<Int, ParseError> { try parse("x") }, swiftc said error: invalid conversion of thrown error type 'any Error' to 'ParseError'. You have to annotate the closure itself, as the next section does.
When to use typed throws: inside a module, or for a small closed set of errors where callers really do switch on every case (parsers, validators). For a public API that may grow new failure reasons, plain throws is still the flexible choice. Adding a case to a typed error later breaks every exhaustive caller.
Result: an error you can store
Result<Success, Failure> is an enum with .success(value) and .failure(error). Use it when the outcome must be stored, passed around or delivered later, for example through a completion callback, in an array of per-item outcomes, or across a boundary that cannot throw.
enum ParseError: Error { case empty, notANumber(String) }
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty }
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
func attempt(_ s: String) -> Result<Int, ParseError> {
Result { () throws(ParseError) -> Int in try parse(s) } // closure must spell out its error type
}
let loose = Result { try parse("12") }
print(type(of: loose), type(of: attempt("x")))
switch attempt("x").map({ $0 * 2 }) {
case .success(let v): print("value", v)
case .failure(.notANumber(let s)): print("bad input", s)
case .failure(.empty): print("empty")
}
print(["1", "x", "3"].map(attempt).compactMap { try? $0.get() })
func load(_ s: String, completion: (Result<Int, ParseError>) -> Void) { completion(attempt(s)) }
load("") { print("callback:", $0) }
Result<Int, Error> Result<Int, ParseError>
bad input x
[1, 3]
callback: failure(result.ParseError.empty)
map transforms only the success value, get() turns a Result back into a throwing call, and switching on .failure(.notANumber(let s)) is exhaustive because the failure type is concrete. With async/await (lecture 5), most completion-callback uses of Result disappear. It remains useful for collecting outcomes.
Generics: constraints, associated types and where clauses
A generic function or type has a type parameter (T), so one implementation works for many types. A constraint (T: Comparable) limits T to types that support what the body needs. A protocol can declare an associated type, a placeholder type that each conforming type fills in. A where clause adds constraints on those placeholders.
// 1. constraint: T must be Comparable
func largest<T: Comparable>(_ xs: [T]) -> T? {
guard var best = xs.first else { return nil }
for x in xs.dropFirst() where x > best { best = x }
return best
}
print(largest([3, 9, 2]) as Any, largest(["pear", "apple"]) as Any, largest([Double]()) as Any)
// 2. protocol with an associated type
protocol Store {
associatedtype Item
mutating func add(_ item: Item)
var all: [Item] { get }
}
struct MemoryStore<Element>: Store { // Item is inferred as Element
private var items: [Element] = []
mutating func add(_ item: Element) { items.append(item) }
var all: [Element] { items }
}
// 3. where clause on the associated type
func summary<S: Store>(_ s: S) -> String where S.Item: Numeric & CustomStringConvertible {
let total = s.all.reduce(0, +)
return "\(s.all.count) items, total \(total)"
}
var ints = MemoryStore<Int>(); ints.add(5); ints.add(7)
print(summary(ints))
// 4. conditional extension: only when Element is Equatable
extension MemoryStore where Element: Equatable {
func contains(_ x: Element) -> Bool { all.contains(x) }
}
print(ints.contains(7))
// 5. primary associated type lets you write `some Collection<Int>`
func sum(_ xs: some Collection<Int>) -> Int { xs.reduce(0, +) }
print(sum([1, 2, 3]), sum(Set([10, 20])))
Optional(9) Optional("pear") nil
2 items, total 12
true
6 30
Point 4 is the one people miss: contains simply does not exist on MemoryStore<User> if User is not Equatable. The extension adds a method only to the specialisations that can support it.
The real compiler errors for unmet constraints
Here is every constraint above broken on purpose, in one file. The first-line messages:
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
struct User { let name: String }
_ = largest([User(name: "a")])
// … Store / MemoryStore as above, then:
_ = summary(MemoryStore<String>())
func addAll<T>(_ xs: [T]) -> T { xs.reduce(0, +) }
_ = MemoryStore<User>().has(User(name: "b")) // has() is in `extension … where E: Equatable`
let s: Store = MemoryStore<Int>()
error: global function 'largest' requires that 'User' conform to 'Comparable'
| `- note: where 'T' = 'User'
error: global function 'summary' requires that 'String' conform to 'Numeric'
| `- note: where 'S.Item' = 'String'
error: referencing instance method 'has' on 'MemoryStore' requires that 'User' conform to 'Equatable'
| `- note: where 'E' = 'User'
error: cannot convert value of type '(Int) -> Int' to expected argument type '(Int, T) throws -> Int'
error: cannot convert return expression of type 'Int' to return type 'T'
warning: use of protocol 'Store' as a type must be written 'any Store'; this will be an error
in a future Swift language mode [#ExistentialAny]
How to read them:
- "requires that 'X' conform to 'Y' … note: where 'T' = 'X'": the message is on the call, and the note tells you which type parameter was bound to what. The fix is either to conform the type (
struct User: Comparable, see exercise 1) or to call a different function. - The
addAllerror is the confusing one. With an unconstrainedT, the compiler does not know that0or+exist forT, so it reports a type mismatch deep insidereduce. It does not say "add a constraint". When you see "cannot convert … to 'T'", ask which operation the body uses and add that protocol (T: Numeric). - The last line is a warning even without the
ExistentialAnyflag. In lecture 3, a barelet s: Shapecompiled silently, but hereStorehas an associated type, and Swift 6.4.2 already insists onany Store.
Measured: generic vs concrete vs any
Does a generic function cost anything? We summed area() over one million Square structs (fixed-seed LCG, seed 42) three ways, with @inline(never) so the functions stay separate. Build: swiftc -O -swift-version 6. Best of 10 per run, 3 runs, Apple Silicon Mac (M1 Pro).
@inline(never) func totalConcrete(_ xs: [Square]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalGeneric<T: Shape>(_ xs: [T]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalAny(_ xs: [any Shape]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
| 1,000,000 elements | -O (3 runs) |
-Onone |
element size |
|---|---|---|---|
concrete [Square] |
0.93–0.94 ms | 72.40 ms | 8 B |
generic <T: Shape> |
0.93 ms | 99.66 ms | 8 B |
existential [any Shape] |
1.87–1.88 ms | 87.48 ms | 40 B |
With -O, the generic was exactly as fast as the hand-written concrete function. We confirmed why in the assembly (swiftc -O -emit-assembly). Besides the generic body, it contains a symbol that swift demangle decodes as generic specialization <bg.Square> of bg.totalGeneric<A where A: bg.Shape>([A]) -> Swift.Double. The optimiser made a private copy of the function for Square. The any version took twice as long: each element is a 40-byte box, and each area() call goes through the witness table.
In the Debug build the order flips: the generic was the slowest (99.66 ms). Without specialisation it passes type metadata and calls through the witness table on every element. This is one more reason never to benchmark in Debug. Also note the limit of specialisation: it needs the compiler to see the generic body. Across a module boundary without @inlinable, a generic library function runs the unspecialised path.
Decision table and diagram
| Situation | Use | Note from our runs |
|---|---|---|
| Bug if it happens (bad literal, missing bundled file) | try! / precondition |
crash, exit 133; buffered stdout may be lost |
| Caller only needs "worked or not" | try? → Optional |
the reason is discarded |
| Caller must react to why, errors may grow (public API) | throws |
catch needs a final generic branch |
| Small closed set, caller switches on every case | throws(E) |
exhaustive catch without default; closures need an explicit throws(E) |
| Outcome stored, collected or delivered later | Result<T, E> |
map, get(); annotate the closure to keep E |
| One algorithm, many element types | generic <T: P> |
specialised at -O: same speed as concrete |
| Truly mixed types in one collection | [any P] |
2× slower in our loop, 40 B per element |
Exercises
Exercise 1. Make largest([User(name: "a", score: 3), User(name: "b", score: 8)]) compile and print the name of the user with the higher score, without changing largest.
Exercise 2. Write func parseAll(_ xs: [String]) -> Result<[Int], ParseError> using the typed parse above. It should stop at the first bad string.
Answers
- Conform
UsertoComparableby giving it a<operator.==is synthesised because all stored properties areEquatable:
struct User: Comparable {
let name: String
let score: Int
static func < (l: User, r: User) -> Bool { l.score < r.score }
}
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
print(largest([User(name: "a", score: 3), User(name: "b", score: 8)])!.name) // b
- Use a typed
doblock and a typed closure, so the failure staysParseError:
func parseAll(_ xs: [String]) -> Result<[Int], ParseError> {
do throws(ParseError) {
return .success(try xs.map { s throws(ParseError) in try parse(s) })
} catch {
return .failure(error)
}
}
print(parseAll(["1", "2", "3"])) // success([1, 2, 3])
print(parseAll(["1", "oops", ""])) // failure(ex4.ParseError.notANumber("oops"))
map stopped at "oops" and never saw the empty string. Array.map rethrows the closure's typed error on this toolchain, which is why the do throws(ParseError) block compiles.
Honest limits and version notes
- Everything ran on Swift 6.4.2 (swiftlang-6.4.2.1.9) only. Typed throws is documented as arriving in Swift 6.0 (SE-0413), but we did not install 6.0 to confirm. On 6.4.2 it worked in both
-swift-version 5and-swift-version 6. Primary associated types (some Collection<Int>) date from Swift 5.7. We verified them only here. - The closure inference of
any ErrorforResult { try … }is the behaviour we saw on 6.4.2. Future compilers may infer the typed error. - The prefix in the crash message (
errors/errors.swift:30) is module/file and points at thetry!line of the code shown. The stdout lines above came from a second build withimport Foundationandfflush(stdout)added before that line; the stderr line came from the first build. - That generics in another module without
@inlinableare not specialised is documented behaviour; we did not build a two-module package to measure it. - Benchmarks are best-of-10 loops with
ContinuousClockin a single file, where the optimiser sees everything.
Previous: Swift lecture 3/6: structs, classes and protocols. Next: Swift lecture 5/6: concurrency, async/await and actors. Related: Dart lecture 4: records, patterns and sealed classes, Java streams, lambdas and Optional, and the C# take on generics and exception handling.
FAQ
Do Swift typed throws need Swift 6?
Typed throws, written throws(ParseError), came with the Swift 6.0 compiler (SE-0413). We verified it only on Swift 6.4.2, where it compiled in both -swift-version 6 and -swift-version 5 language modes. Inside catch, error then has the concrete type, so a switch over it needs no default. One surprise: Result { try parse(s) } still inferred Result<Int, Error>; you have to spell the closure as () throws(ParseError) -> Int.
Is a generic function slower than a concrete one in Swift?
Not in an optimised build where the compiler can see the call. With swiftc -O, summing one million structs took 0.93 ms through a concrete function and 0.93 ms through a generic T: Shape function, because the compiler emitted a generic specialization for Square. The same loop over [any Shape] took 1.87 ms, about twice as long.
What happens when try! fails?
The program stops with Fatal error: 'try!' expression unexpectedly raised an error, followed by the error's description, and exits with code 133 (SIGTRAP) on macOS. In our run, everything printed before the crash was lost when stdout was redirected to a file, because the buffer was never flushed.