- Published on
- 9 min read Intermediate
> Error Handling in Swift: From Basics to Typed Throws
// What_You_Will_Learn
- Use throw/try/catch and understand try? vs try!
- Create custom error types with LocalizedError
- Handle errors in async/await and task groups
- Apply typed throws from Swift 6 for compile-time safety
Swift's error handling system is built around a few simple concepts: functions declare that they can fail with throws, callers handle failures with do-try-catch, and error types carry information about what went wrong. It sounds straightforward, but there's real depth here once you start building production apps.
The Basics
Any function that can fail is marked with throws:
enum ValidationError: Error {
case tooShort
case invalidCharacter(Character)
}
func validate(username: String) throws -> String {
guard username.count >= 4 else {
throw ValidationError.tooShort
}
for char in username {
guard char.isLetter || char.isNumber else {
throw ValidationError.invalidCharacter(char)
}
}
return username
}
Callers use do-try-catch to handle errors:
do {
let name = try validate(username: input)
createAccount(with: name)
} catch ValidationError.tooShort {
showError("Username must be at least 4 characters")
} catch ValidationError.invalidCharacter(let char) {
showError("Username can't contain '\(char)'")
} catch {
showError("Something went wrong: \(error.localizedDescription)")
}
Swift evaluates catch clauses top to bottom, so put specific catches before general ones. The final catch without a pattern acts as a catch-all, and the implicit error variable gives you access to whatever was thrown.
The Three Flavors of try
try requires a do-catch block. try? converts the result to an optional, returning nil on failure:
let name = try? validate(username: input)
// name is String? -- nil if validation failed
This is useful when you genuinely don't care why something failed. But overusing try? is an anti-pattern because it hides the reason for failure. If a network request fails, knowing whether it was a timeout vs. an authentication error changes how you respond.
try! force-unwraps the result and crashes if an error is thrown:
let data = try! Data(contentsOf: Bundle.main.url(forResource: "config", withExtension: "json")!)
Reserve try! for situations where failure genuinely represents a programming error, like loading a bundled resource that must exist for the app to function.
Custom Error Types
The Error protocol has no requirements, so any type can conform. Enums are the natural fit because they represent a finite set of failure cases:
enum NetworkError: Error {
case noConnection
case timeout
case serverError(statusCode: Int)
case decodingFailed(underlying: Error)
}
Associated values let you attach context to each error case. The underlying parameter in decodingFailed preserves the original error for debugging while presenting a clean domain error to callers.
LocalizedError for User-Facing Messages
Conforming to LocalizedError gives your errors human-readable descriptions:
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .noConnection:
return "No internet connection. Check your network settings."
case .timeout:
return "The request timed out. Please try again."
case .serverError(let code):
return "Server returned an error (code \(code))."
case .decodingFailed:
return "The response couldn't be read."
}
}
}
Without LocalizedError, calling error.localizedDescription returns a generic message. With it, your errors produce specific, actionable text you can show directly in alerts or error views.
The Result Type
Result<Success, Failure> wraps either a success value or an error into a single type:
func fetchUser(id: Int) -> Result<User, NetworkError> {
guard isConnected else {
return .failure(.noConnection)
}
do {
let user = try decoder.decode(User.self, from: data)
return .success(user)
} catch {
return .failure(.decodingFailed(underlying: error))
}
}
You can switch over the result or use .get() to convert it back into a throwing expression:
switch fetchUser(id: 42) {
case .success(let user):
display(user)
case .failure(let error):
showError(error.localizedDescription)
}
// Or convert to try/catch
do {
let user = try fetchUser(id: 42).get()
display(user)
} catch {
showError(error.localizedDescription)
}
Since async/await arrived, Result has become less common for network calls because async throws handles that pattern more naturally. But Result still shines for representing stored outcomes, like a cached computation or a state enum:
enum LoadState<T> {
case idle
case loading
case loaded(Result<T, Error>)
}
Error Handling in Async Code
Async functions combine async and throws naturally:
func fetchPosts(for user: User) async throws -> [Post] {
let url = URL(string: "https://api.example.com/users/\(user.id)/posts")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.serverError(statusCode: 0)
}
guard http.statusCode == 200 else {
throw NetworkError.serverError(statusCode: http.statusCode)
}
return try JSONDecoder().decode([Post].self, from: data)
}
When running concurrent work with task groups, you have two strategies. withThrowingTaskGroup fails fast, cancelling remaining tasks when the first error occurs:
func fetchAllData() async throws -> DashboardData {
try await withThrowingTaskGroup(of: Sendable.self) { group in
var user: User?
var posts: [Post] = []
group.addTask { try await self.fetchUser() }
group.addTask { try await self.fetchPosts() }
for try await result in group {
if let u = result as? User { user = u }
if let p = result as? [Post] { posts = p }
}
guard let user else { throw AppError.missingData }
return DashboardData(user: user, posts: posts)
}
}
For fault-tolerant operations where partial results are acceptable, use a non-throwing group with try?:
func fetchAvailableImages(urls: [URL]) async -> [UIImage] {
await withTaskGroup(of: UIImage?.self) { group in
for url in urls {
group.addTask { try? await self.downloadImage(from: url) }
}
var images: [UIImage] = []
for await image in group {
if let image { images.append(image) }
}
return images
}
}
Typed Throws in Swift 6
Swift 6 introduced typed throws via throws(ErrorType), letting you specify exactly which error type a function can throw:
enum ParseError: Error {
case invalidFormat
case missingField(String)
case typeMismatch(expected: String, got: String)
}
func parseConfig(from data: Data) throws(ParseError) -> Config {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw .invalidFormat
}
guard let name = json["name"] as? String else {
throw .missingField("name")
}
return Config(name: name)
}
Inside the catch block, error is automatically typed, so you get exhaustive switch handling without casting:
do {
let config = try parseConfig(from: data)
apply(config)
} catch {
// error is ParseError, not any Error
switch error {
case .invalidFormat:
print("Bad format")
case .missingField(let field):
print("Missing: \(field)")
case .typeMismatch(let expected, let got):
print("Expected \(expected), got \(got)")
}
}
Typed throws also works with generics. A function that accepts a throwing closure can propagate the exact error type:
func retry<T, E>(
times: Int,
operation: () throws(E) -> T
) throws(E) -> T {
for attempt in 1..<times {
do {
return try operation()
} catch {
continue
}
}
return try operation()
}
If the closure is non-throwing, E becomes Never and the whole function becomes non-throwing. The compiler handles this automatically.
That said, typed throws is best suited for internal APIs with stable error contracts. For public library APIs or code where error types might evolve, untyped throws remains more flexible and is still the recommended default for most Swift code.
Layered Error Architecture
In production apps, define error types at each layer and map between them:
// Network layer
enum APIError: Error {
case unauthorized
case notFound
case serverError(Int)
case networkFailure(underlying: Error)
}
// Domain layer
enum UserServiceError: Error {
case notFound
case sessionExpired
case unavailable
}
class UserService {
private let api: APIClient
func getUser(id: Int) async throws(UserServiceError) -> User {
do {
return try await api.fetch(User.self, from: "/users/\(id)")
} catch let error as APIError {
switch error {
case .unauthorized: throw .sessionExpired
case .notFound: throw .notFound
default: throw .unavailable
}
} catch {
throw .unavailable
}
}
}
This separation means the UI layer never sees raw API errors, and changes to your networking stack don't ripple through the entire app.
Common Mistakes
Using try? everywhere because it's easy. This swallows error information and makes debugging painful. If a Keychain read fails, knowing whether it was a missing item vs. a permission error changes your response entirely.
Catching errors too early. If a utility function catches and logs an error internally, the caller never knows something failed. Let errors bubble up to the layer that has enough context to respond meaningfully, usually the view or view model.
Forgetting catch clause order. Swift evaluates catches top to bottom. A generic catch before a specific catch let error as NetworkError means the specific clause never runs. Always put specific catches first.
Conforming to Error with a class instead of an enum. While any type can conform, enums naturally enforce a finite set of cases and work with exhaustive switch statements. Classes make error handling harder to reason about.
// Frequently_Asked
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Typed Throws in Swift 6: Declaring Specific Error Types
Swift 6 introduces typed throws, letting you declare exactly which error types a function can throw. Learn how this improves API contracts and enables exhaustive error handling.
Defensive Coding with NSError in Objective-C
NSError is the standard error handling mechanism in Objective-C, but using it defensively requires more than just passing a pointer. Learn patterns that prevent crashes and make debugging easier.
Why Objective-C Has try-catch But Nobody Uses It
Objective-C supports exception handling with @try/@catch, but the community settled on NSError pointers instead. Here's why.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.