This episode covers error handling in Swift with throws, try, catch, and defer, creating custom error types, and common error patterns. You will also learn debugging in Xcode with breakpoints and LLDB, plus assertions and unit tests to validate program behavior.

A good program doesn't just run when everything is right — it also behaves gracefully when things go wrong. Episode 6 covers error handling and debugging: how Swift models failures explicitly with throws, try, and catch, and how you track down and fix bugs with breakpoints, LLDB, and tests.
One principle you'll hold onto forever: errors in Swift are never hidden. A function that can fail declares its capability through its type, and the compiler forces callers to handle it. This contrasts with languages that let failures stay invisible until a crash in production.
Functions that can throw errors are marked throws, and callers handle them with do-catch:
enum KesalahanBagi: Error {
case pembagiNol
}
func bagi(_ a: Int, _ b: Int) throws -> Int {
guard b != 0 else {
throw KesalahanBagi.pembagiNol
}
return a / b
}
do {
let hasil = try bagi(10, 0)
print(hasil)
} catch KesalahanBagi.pembagiNol {
print("Pembagi tidak boleh nol")
}try bagi(10, 0) calls a potentially error-throwing function inside a do block. When an error is thrown, execution moves to the matching catch block. Proper handling turns failure into a controlled flow.
When an error can be thrown in the middle of a function, resource cleanup must still happen. The defer block runs its code when leaving the scope, no matter which path is taken:
func bacaFile(path: String) throws -> String {
let file = open(path)
defer {
close(file)
print("File ditutup")
}
// Operasi yang bisa melempar
return "Isi file"
}defer { close(file) } ensures close(file) is called whether the function returns a value or throws an error. Multiple defer blocks execute in the reverse order of their declaration — keep this in mind when arranging nested initializations.
An error in Swift is any type that adopts the Error protocol. Best practice: use enums with associated values to carry context:
enum ErrorJaringan: Error {
case tidakAdaKoneksi
case statusBuruk(code: Int)
case payloadTidakValid(pesan: String)
}
func kirimRequest() throws {
throw ErrorJaringan.statusBuruk(code: 503)
}
do {
try kirimRequest()
} catch ErrorJaringan.statusBuruk(let code) where code >= 500 {
print("Server bermasalah: \(code)")
} catch {
print("Error lain: \(error)")
}catch ErrorJaringan.statusBuruk(let code) unpacks the associated value and combines it with where for filtering. Catching all other errors with a bare catch ensures no failure goes unnoticed.
For asynchronous operations, the classic throws pattern doesn't apply because errors can't be thrown across closure boundaries. Instead, Swift provides the Result enum:
func proses(data: String) -> Result<Int, Error> {
guard let angka = Int(data) else {
return .failure(ErrorJaringan.payloadTidakValid(pesan: data))
}
return .success(angka * 2)
}
let hasil = proses(data: "21")
print(hasil)Result<Int, Error> wraps success or failure in a single value. Episodes 8 and 9 will use this pattern alongside async/await for networking and concurrency.
Xcode ships with the integrated LLDB debugger. You can halt execution with breakpoints, then inspect state from the console. A set of commands worth memorizing:
breakpoint set --file main.swift --line 10
run
print skor
step
next
continuebreakpoint set --file main.swift --line 10 sets a breakpoint before running the program. In the LLDB console, print skor displays the current variable value, next executes the following line, and continue resumes execution until the next breakpoint.
When a crash occurs, the stack trace shows the chain of function calls. Start reading from the topmost frame (the crash location), then work downward looking for frames belonging to your code — frames from Apple frameworks are usually safe to skip. Xcode's automatic symbolication translates memory addresses back into function names and lines.
assert and precondition enforce invariants that, when violated, mean there's a bug:
func prosesNilai(_ nilai: Int) {
precondition(nilai >= 0, "nilai harus non-negatif")
assert(nilai < 1000, "nilai terlalu besar untuk diproses")
print("Memproses \(nilai)")
}
prosesNilai(42)precondition(nilai >= 0) is always active, even in production builds — suitable for conditions that must never be violated. assert is only active in debug builds and removed on release, useful for expensive checks. Use both to find bugs earlier.
Although episode 15 covers testing in depth, you can start verifying behavior now with XCTest:
import XCTest
final class BagiTests: XCTestCase {
func testPembagianNormal() throws {
let hasil = try bagi(10, 2)
XCTAssertEqual(hasil, 5)
}
}XCTAssertEqual(hasil, 5) checks a function's result. Tests become a safety net when you change code — every time a feature changes, tests tell you whether old behavior broke.
Tip
Start building the habit of writing tests alongside features, not afterwards. Tests written after a bug is found often just lock in the wrong behavior.
Key takeaways:
throws, called with try, and handled in do-catch.defer blocks guarantee resource cleanup no matter which execution path is taken.Result wraps success and failure for asynchronous operations.precondition and assert enforce internal invariants early.In the next episode, episode 7, we'll cover data persistence and file I/O — working with FileManager and URL to read and write local files, serialization with the Codable protocol for JSON and plist, lightweight storage with UserDefaults and Keychain, and an overview of Core Data for complex persistence. Your data will start being stored safely!