Skip to content
Theory · Final project · lesson 3 of 5

Parsing input and errors

So far the receipts reached the ledger straight from the code. In real life they are entered as text: for example, you jot your spending into a notes app on your phone, one line per purchase: food 12.5, taxi 7. The program has to turn each such line into a receipt.

But lines can contain mistakes. fod 3 has a typo in the category, taxi abc has letters instead of a cost, and food forgot the cost. A careful bookkeeper doesn't guess what was meant. They set such a receipt aside with a note giving the reason and enter the rest into the ledger.

Parsing is turning text into the program's values: here, lines into receipts. Let's start with one correct line:

SWIFT
enum Category: String, CaseIterable {
    case food, taxi
}
struct Expense {
    let category: Category
    let cost: Double
}

let parts = "taxi 7".split(separator: " ")
let category = Category(rawValue: String(parts[0]))
let cost = Double(parts[1])
if let category, let cost {
    let ride = Expense(category: category, cost: cost)
    print(ride.category.rawValue, ride.cost) // taxi 7.0
}

Let's take the program step by step:

  1. split(separator: " ") cuts the line at the space into two parts: taxi and 7.
  2. The parts have the type Substring: they are pieces of the original string. Category(rawValue:) expects exactly a String, so the piece is wrapped in String(…).
  3. Double(parts[1]) reads the number, and it accepts the piece as it is.
  4. Both the category and the cost are optionals. if let category, let cost opens both boxes, and the program prints taxi 7.0.

What can go wrong

A line can be wrong in three ways:

  • fod 3: there is no fod tab, and Category(rawValue:) gives nil;
  • taxi abc: abc holds no number, and Double gives nil; the same happens with 12,5: Swift expects a point, not a comma;
  • food: there is only one part, and parts[1] crashes the program.
TEXT
Fatal error: Index out of range

With if let, the first two lines silently fail the check, and the person never learns why. And the third one stops the whole program. We need a function that checks a line step by step and names the reason it was rejected. Swift has errors for this.

The parse function

Let's describe the three reasons with an enum and throw the right one when a check fails:

SWIFT
enum InputError: Error {
    case badFormat, badCategory, badCost
}

func parse(_ line: String) throws -> Expense {
    let parts = line.split(separator: " ")
    guard parts.count == 2 else { throw InputError.badFormat }
    guard let category = Category(rawValue: String(parts[0])) else {
        throw InputError.badCategory
    }
    guard let cost = Double(parts[1]) else { throw InputError.badCost }
    return Expense(category: category, cost: cost)
}

Let's take the function apart:

  1. InputError names the reasons: not two parts, an unknown category, a cost that is not a number.
  2. The first guard checks the number of parts. It comes before parts[1], so the line food no longer crashes the program.
  3. The second guard looks up the tab by its label, and the third reads the cost.
  4. When every check passes, parse returns a ready receipt.

The constants parts, category and cost inside the function are its own. They have nothing to do with the constants of the same names in the first block.

Bad lines in catch

Now let's read a whole list of lines. Each line is parsed in its own do, so a mistake in one line doesn't get in the way of the others:

SWIFT
let lines = ["food 12.5", "taxi abc", "fod 3", "taxi 7", "food", "food 4.25"]
var expenses: [Expense] = []
for line in lines {
    do {
        expenses.append(try parse(line))
    } catch {
        print("skip \"\(line)\":", error)
    }
}
let sum = expenses.reduce(0) { $0 + $1.cost }
print(expenses.count, "sum", sum) // 3 sum 23.75

This block prints four lines:

  • skip "taxi abc": badCost
  • skip "fod 3": badCategory
  • skip "food": badFormat
  • 3 sum 23.75

Let's see what happened:

  1. The do/catch sits inside the loop, so the error of one line is caught, and the loop moves on to the next one.
  2. In catch the caught error is in the constant error, and print shows its case, the reason the line was rejected.
  3. The three good lines became receipts for 12.5, 7.0 and 4.25, and together they make 23.75.

Interviewers like to ask when to catch an error with do/catch and when try? is enough. The answer: try? turns an error into nil, and lines.compactMap { try? parse($0) } gives the same three receipts in one line. But together with the bad lines it discards the reasons too. If the person needs to know what to fix, catch the error in catch.

Here the bookkeeper picture is inaccurate. A bookkeeper also notices an odd receipt, such as a lunch for a million. The program catches only what parse checks, and it accepts the line food 1000000 without a question.

Common mistake

A piece of the line was passed to Category(rawValue:) without String(…):

SWIFT
let tab = Category(rawValue: parts[0]) // ✗
TEXT
error: cannot convert value of type 'String.SubSequence' (aka 'Substring') to expected argument type 'String'

The program does not compile. split returns pieces of type Substring, while Category(rawValue:) accepts only a String. This mistake is common because Double(parts[1]) does accept the same piece. Wrap the piece: Category(rawValue: String(parts[0])).

Key points

  • split(separator: " ") cuts a line into pieces of type Substring; for Category(rawValue:) a piece is wrapped in String(…), while Double(…) accepts it as it is.
  • parse checks a line step by step with guard and throws its own InputError case for each reason; the number of parts is checked before parts[1].
  • A do/catch inside the loop catches the error of one line, names the reason and lets the loop go on.
  • compactMap { try? parse($0) } is shorter, but it silently discards the bad lines together with the reasons.