Skip to content

11. Files & error handling

What & why

Real programs read and write files, and real I/O fails: the file is missing, the disk is full, the data is malformed. Swift's error handling is explicit — functions that can fail are marked throws, and callers must acknowledge it with try. Nothing is thrown "invisibly."

Defining and throwing errors

Errors are usually enums conforming to Error:

enum ParseError: Error {
    case empty
    case notANumber(String)
    case negative(Int)
}

func parsePositive(_ text: String) throws -> Int {
    guard !text.isEmpty else { throw ParseError.empty }
    guard let n = Int(text) else { throw ParseError.notANumber(text) }
    guard n >= 0 else { throw ParseError.negative(n) }
    return n
}

throws goes before the return arrow. throw leaves the function immediately.

Catching errors: do–catch

do {
    let n = try parsePositive("42")
    print("Got \(n)")
    let m = try parsePositive("-7")     // throws here...
    print("Got \(m)")                   // ...so this line never runs
} catch ParseError.empty {
    print("Input was empty")
} catch ParseError.notANumber(let text) {
    print("'\(text)' is not a number")
} catch {
    print("Other error: \(error)")      // `error` is implicit in a bare catch
}

Every call to a throwing function needs try (or a variant), and it must sit in a do block with a catch, or inside another throwing function.

try?, try!, and propagating

let a = try? parsePositive("12")     // Int? — nil if it threw (error is discarded)
let b = try! parsePositive("12")     // crashes if it throws — only for "can't fail" cases

func sumAll(_ texts: [String]) throws -> Int {      // propagate: caller deals with it
    var total = 0
    for t in texts {
        total += try parsePositive(t)
    }
    return total
}

Rule of thumb: handle errors where you can do something useful about them, and propagate everywhere else.

defer — cleanup that always runs

func work() throws {
    print("open")
    defer { print("close") }       // runs when the scope exits — normally OR by throw/return
    try riskyStep()
    print("finished")
}
func riskyStep() throws { throw ParseError.empty }

Several defers run in reverse order. Use them to release resources next to where you acquire them.

Making errors descriptive

Conform to LocalizedError for human-friendly messages:

import Foundation

enum ConfigError: LocalizedError {
    case missing(String)
    var errorDescription: String? {
        switch self {
        case .missing(let key): return "Missing config key: \(key)"
        }
    }
}

do { throw ConfigError.missing("port") }
catch { print(error.localizedDescription) }    // Missing config key: port

Reading and writing files

File APIs live in Foundation, which works on macOS, Linux, and Windows.

import Foundation

let path = "notes.txt"

// Write
try "first line\nsecond line\n".write(toFile: path, atomically: true, encoding: .utf8)

// Read the whole file
let contents = try String(contentsOfFile: path, encoding: .utf8)
let lines = contents.split(separator: "\n")
print(lines.count)                              // 2

Using URLs (preferred for new code):

let url = URL(fileURLWithPath: "notes.txt")
let text = try String(contentsOf: url, encoding: .utf8)
try text.uppercased().write(to: url, atomically: true, encoding: .utf8)

Binary data uses Data:

let data = try Data(contentsOf: url)
print(data.count)
try data.write(to: URL(fileURLWithPath: "copy.bin"))

Handling "file not found" gracefully

do {
    let s = try String(contentsOfFile: "missing.txt", encoding: .utf8)
    print(s)
} catch {
    print("Could not read file: \(error.localizedDescription)")
}

Files and directories

let fm = FileManager.default

print(fm.currentDirectoryPath)
print(fm.fileExists(atPath: "notes.txt"))

try fm.createDirectory(atPath: "out/logs", withIntermediateDirectories: true)
let entries = try fm.contentsOfDirectory(atPath: ".")
print(entries.sorted())

try fm.removeItem(atPath: "out")                // deletes the directory tree — be careful!

Build paths with URL, not string concatenation:

let dir = URL(fileURLWithPath: "data", isDirectory: true)
let file = dir.appendingPathComponent("scores.csv")
print(file.path, file.lastPathComponent, file.pathExtension)    // .../scores.csv scores.csv csv

Command-line arguments & exit codes

import Foundation

let args = CommandLine.arguments           // [0] is the program path
guard args.count >= 2 else {
    FileHandle.standardError.write(Data("usage: wordcount <file>\n".utf8))
    exit(2)
}
let text = try String(contentsOfFile: args[1], encoding: .utf8)
print(text.split(whereSeparator: \.isWhitespace).count)

Top-level try is allowed in main.swift; an uncaught error ends the program with a message. Convention: exit code 0 = success, non-zero = failure.

Result — errors as values

Sometimes you want to store or pass a success-or-failure outcome:

let outcomes = ["5", "x", "-1"].map { text in
    Result { try parsePositive(text) }      // Result<Int, any Error>
}
for outcome in outcomes {
    switch outcome {
    case .success(let n): print("ok", n)
    case .failure(let e): print("failed:", e)
    }
}

Prefer plain throws for new synchronous code; Result shines when you need to store or pass along outcomes (Intermediate covers it properly).

Common mistakes

  • Writing try without a do/catch or an enclosing throws — the compiler will insist.
  • Swallowing errors with try? everywhere, losing the reason a failure occurred.
  • Using try! on I/O — files go missing. Handle it.
  • Catch-all catch { } that hides the error. At least print it.
  • Building paths with + "/" + — breaks on Windows; use URL.appendingPathComponent.
  • Forgetting that relative paths depend on the current working directory (swift run runs from the package root).
  • Forgetting import Foundation for file APIs.

Exercises

  1. Write func readNumbers(from path: String) throws -> [Int] that reads a file of one integer per line and throws a custom error naming the first bad line.
  2. Write a wordcount tool that takes a file path argument and prints lines, words, and characters. Exit with code 2 and a usage message if no argument is given.
  3. Write a function that copies a file, and use defer to print a "done" message regardless of failure.
  4. Write safeRead(_ path: String) -> String? using try?, then a second version that returns Result<String, Error>. Which tells the caller more?
  5. List all .swift files under a directory recursively using FileManager.default.enumerator(atPath:).
  6. Make an error enum with a LocalizedError description, throw it from three different places, and catch each case separately.

Recap

  • Mark failing functions throws; call them with try inside do/catch (or another throws function).
  • try? → optional, try! → crash on error; use them sparingly.
  • defer guarantees cleanup; conform errors to LocalizedError for friendly messages.
  • Foundation provides String(contentsOfFile:), Data, URL, and FileManager.
  • Use CommandLine.arguments and exit(_:) for command-line tools.

Next: Capstone: task tracker →