Unlocking the Real Power of Swift 6's Typed Throws with Error Chains
Discover how to turn Typed Throws from a headache into a superpower — with clean error handling and powerful debugging insights.

Swift 6 finally introduced one of the most requested features to Swift: Typed Throws. This improvement allows you to specify exactly which error types a function can throw, bringing Swift's type safety to error handling. But with this power comes a new challenge I would call "nesting hell" — a problem that affects how errors propagate across layers of your application.
In this post, I'll explain the nesting problem and show you how I've solved it in ErrorKit with a simple protocol that makes typed throws practical without boilerplate. As a bonus, you'll see how proper error chaining can dramatically improve your debugging experience.
Typed Throws: The Promise and the Problem
First, let's look at what Typed Throws gives us in Swift 6:
// Instead of just 'throws', we can specify the error type
func processFile() throws(FileError) {
if !fileExists {
throw FileError.fileNotFound(fileName: "config.json")
}
// Implementation...
}
This enables better error handling at the call site:
do {
try processFile()
} catch FileError.fileNotFound(let fileName) {
print("Could not find file: \(fileName)")
} catch FileError.readFailed {
print("Could not read file")
}
// No generic catch needed if we've handled all possible FileError cases!
The benefits are clear:
- Compile-time verification of error handling
- No need for type casting with
as?
in catch blocks - Self-documenting API that tells callers exactly what can go wrong
- IDE autocompletion for error cases
The Nesting Hell Problem
The problem arises when working with multi-layered applications. Consider this:
// Database layer throws DatabaseError
func fetchUser(id: String) throws(DatabaseError) {
// Database operations...
}
// Profile layer needs to call the database layer
func loadUserProfile(id: String) throws(ProfileError) {
do {
// ⚠️ Problem: This throws DatabaseError, not ProfileError
let user = try fetchUser(id: id)
} catch {
// Manual error conversion needed
switch error {
case DatabaseError.recordNotFound:
throw ProfileError.userNotFound
default:
throw ProfileError.databaseError(error) // Need a wrapper case
}
}
}
This creates several problems:
- Wrapper Cases Explosion:
Every error type needs wrapper cases for all possible child errors - Manual Error Mapping:
Repetitive do-catch blocks with explicit error conversion - Type Proliferation:
Error types grow with each layer, becoming harder to maintain - Lost Context:
Details about the original error often get lost in translation
For small apps, this might be manageable. For larger apps with many layers, it quickly becomes what can be described as "nesting hell".
The Solution: The Catching Protocol
ErrorKit solves this with a simple protocol called Catching
:
public protocol Catching {
static func caught(_ error: Error) -> Self
}
This protocol requires a single enum case named caught
that wraps any error into your type. Here's how you use it:
enum ProfileError: Throwable, Catching {
case userNotFound
case invalidProfile
case caught(Error) // Single case for all other errors
var userFriendlyMessage: String {
switch self {
case .userNotFound:
return "User not found."
case .invalidProfile:
return "Profile data is invalid."
case .caught(let error):
// Use the wrapped error's message
return ErrorKit.userFriendlyMessage(for: error)
}
}
}
Note that Throwable
is a drop-in replacement for Error
(see previous post).
Now, the magic happens with the catch
function that comes with the protocol:
func loadUserProfile(id: String) throws(ProfileError) {
// For known errors, throw them directly
guard isValidID(id) else {
throw ProfileError.invalidInput
}
// For operations that may throw other error types, use the catch function
let user = try ProfileError.catch {
// Any error thrown here will be automatically wrapped
// into ProfileError.caught(error)
return try fetchUser(id: id)
}
// Rest of implementation...
}
Note that the catch
function returns whatever you return in the closure.
The catch
function automatically wraps any errors thrown in its closure into your error type. No manual do-catch blocks, no explicit error mapping—it just works. Even multiple try
expressions are possible.
The catch Function's Secret Sauce
The catch
function is elegantly simple:
extension Catching {
public static func `catch`<ReturnType>(
_ operation: () throws -> ReturnType
) throws(Self) -> ReturnType {
do {
return try operation()
} catch {
throw Self.caught(error)
}
}
}
This function:
- Takes a throwing closure
- Tries to execute it
- Returns the result if successful
- Automatically wraps any thrown error using your
caught
case - Preserves the return type of the operation
The best part? It works seamlessly with Swift 6's typed throws, maintaining type safety while eliminating boilerplate.
Preserving the Error Chain for Debugging
One of the biggest benefits of this approach is that it preserves the complete error chain. Instead of losing context when errors cross boundaries, each layer adds information while keeping the original error intact.
ErrorKit leverages this to provide powerful debugging with the errorChainDescription(for:)
function:
do {
try await updateUserProfile()
} catch {
print(ErrorKit.errorChainDescription(for: error))
// Output shows the complete chain:
// AppError
// └─ ProfileError
// └─ DatabaseError
// └─ FileError.notFound(path: "/Users/data.db")
// └─ userFriendlyMessage: "Could not find database file."
}
This hierarchical view tells you:
- Where the error originated (FileError)
- The exact path it took through your application (FileError → DatabaseError → ProfileError → AppError)
- The specific details of what went wrong (file not found, with the path)
- The user-friendly message that would be shown to users
This level of insight is invaluable during debugging, especially for complex applications where errors might originate deep in the call stack.
Structured Error Chain Output
The error chain description works by recursively inspecting the error structure:
static func errorChainDescription(for error: Error) -> String {
// Recursive implementation that builds a hierarchical description
Self.chainDescription(for: error, enclosingType: type(of: error))
}
See here for the full implementation of chainDescription
inside ErrorKit.
The function uses Swift's reflection capabilities to:
- Inspect the error using the Mirror API
- For errors conforming to
Catching
, extract the wrapped error - For enum errors, capture case names and associated values
- For struct or class errors, include type metadata
- Format everything in a hierarchical tree structure
This provides far more information than standard error logging, particularly for complex error hierarchies.
Built-in Support in ErrorKit
All of ErrorKit's built-in error types (like FileError
or NetworkError
) already conform to Catching
, so you can use them right away:
func saveUserData() throws(DatabaseError) {
// Automatically wraps SQLite errors, file system errors, etc.
try DatabaseError.catch {
try database.beginTransaction()
try database.execute(query)
try database.commit()
}
}
Real-World Example: A Typical Application
Let's see how this works in a more complete example:
// Data Access Layer
func fetchUserData(id: String) throws(DatabaseError) {
guard database.isConnected else {
throw DatabaseError.connectionFailed
}
// This could throw file system errors
try DatabaseError.catch {
let query = try QueryBuilder.build(for: id)
return try database.execute(query)
}
}
// Business Logic Layer
func processUserProfile(id: String) throws(ProfileError) {
guard isValidID(id) else {
throw ProfileError.invalidInput
}
// This automatically wraps DatabaseError
let userData = try ProfileError.catch {
return try fetchUserData(id: id)
}
// Process the user data...
}
// Presentation Layer
func displayUserProfile(id: String) throws(UIError) {
// This automatically wraps ProfileError (which might contain DatabaseError)
let profile = try UIError.catch {
return try processUserProfile(id: id)
}
// Display the profile...
}
If a database connection fails, here's what you'll see in the error chain:
UIError
└─ ProfileError
└─ DatabaseError.connectionFailed
└─ userFriendlyMessage: "Unable to establish a connection to the database. Check your network settings and try again."
This tells you exactly what happened and where the error originated, making debugging much easier. The added context can give you the right hint to fix the issue!
Conclusion
Swift 6's typed throws is a powerful addition to the language, but it introduces challenges for error propagation across layers. The Catching
protocol offers a simple, elegant solution that maintains type safety while eliminating boilerplate.
Combined with ErrorKit's errorChainDescription
function, error handling becomes a powerful debugging tool. Use ErrorKit now and profit from many other improvements that make error handling in Swift more useful in real-world apps:
Have you started using Swift 6's typed throws? How are you handling error propagation across layers in your apps? Let me know on socials (links below)!
A simple & fast AI-based translator for String Catalogs & more.
Get it now and localize your app to over 100 languages in minutes!