Chapter 11: Making it robust, validated input and errors
This chapter:
- Meet
or_return, the shortcut for "bail out with the same failure" you've been building toward. - Write one reusable
read_int_in_rangehelper, replacing every ad-hoc retry loop since Chapter 3. - Make
load_notescount and report corrupt lines instead of silently dropping them. - Draw the line between where to validate and where to trust your own code.
- Apply both to Muninn's add form and delete prompt.
Where we left off: Chapter 10 finished a Muninn that works, as long as everyone types sensible things. This chapter is about everyone else: the mistyped id, the hand-edited save file, the empty line where a choice was expected.
11.1 Odin's error idiom, named properly
You've used (value, ok) since Chapter 3 without a name for the pattern. It's Odin's idiom for "this might not work, and here's whether it did": no exceptions, no panics for an ordinary failure, just an extra return value the caller is expected to check. or_return is the shortcut for the most common thing you do with one: if it failed, bail out of this procedure the same way, right now.
parse_two :: proc(a_str, b_str: string) -> (sum: int, ok: bool) {
a := strconv.parse_int(a_str) or_return
b := strconv.parse_int(b_str) or_return
return a + b, true
}
sum, ok := parse_two("3", "4")
fmt.println(sum, ok) // 7 true
sum2, ok2 := parse_two("3", "banana")
fmt.println(sum2, ok2) // 0 false
strconv.parse_int(a_str) or_return means: call parse_int, and if its ok comes back false, immediately return from parse_two with ok = false and every other named result at its zero value, without you writing the if !ok { return 0, false } by hand. This works because parse_two's own last return value is a bool too; or_return propagates the failure straight through when the shapes line up like that.
Why introduce this only now, in Chapter 11, rather than back in Chapter 3? Because
or_returnis a genuine shortcut, and shortcuts are easiest to appreciate once you've felt the thing they're shortcutting. Everyif !ok { ... }you've written by hand since Chapter 3 was real, useful practice with the patternor_returnnow collapses: you'll recognise exactly what it's doing.
11.2 One validated-read helper, instead of scattered retry loops
Chapter 3's read_celsius, Chapter 6's guess loop, and Chapter 10's do_delete have each written their own "keep asking until the answer is valid" loop. That's real repetition, and it's exactly what a reusable procedure is for. input.odin gains one:
// input.odin
package main
import "core:bufio"
import "core:fmt"
import "core:strconv"
import "core:strings"
read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
if !bufio.scanner_scan(scanner) {
return "", false
}
return strings.trim_space(bufio.scanner_text(scanner)), true
}
// read_int_in_range loops until the user types a whole number between lo and hi
// inclusive, or input runs out entirely.
read_int_in_range :: proc(scanner: ^bufio.Scanner, prompt: string, lo, hi: int) -> (value: int, ok: bool) {
for {
fmt.print(prompt)
line, has_line := read_line(scanner)
if !has_line {
return 0, false
}
n, parsed := strconv.parse_int(line)
if !parsed {
fmt.println("Please enter a whole number.")
continue
}
if n < lo || n > hi {
fmt.printfln("Please enter a number between %d and %d.", lo, hi)
continue
}
return n, true
}
}
read_int_in_range folds two checks into one loop: is it a number at all, and is it in bounds. It returns ok = false only when input genuinely runs out (Ctrl-D, a closed pipe); a bad guess just re-prompts, same as every retry loop we've written since Chapter 3, now written once.
11.3 Applying it: delete gets range-checked
Chapter 10's do_delete parsed the id with a bare strconv.parse_int and let find_note report "no note with that id" for anything out of range. Swap in read_int_in_range, bounded by what ids could actually exist right now:
do_delete :: proc(store: ^Store, scanner: ^bufio.Scanner) {
print_notes_table(list_notes(store))
if len(store.notes) == 0 {
return
}
id, ok := read_int_in_range(scanner, "Delete which id? ", 1, store.next_id)
if !ok {
return
}
n, found := find_note(store, id)
if !found {
fmt.println("No note with that id.")
return
}
fmt.printfln("Delete \"%s\"? (y/n): ", n.title)
confirm, confirm_ok := read_line(scanner)
if !confirm_ok || confirm != "y" {
fmt.println("Cancelled.")
return
}
delete_note(store, id)
save_notes(store)
fmt.println("Deleted.")
}
$ odin run .
...
Choose: 4
1 Shopping Milk and eggs
Delete which id? 99
Please enter a number between 1 and 1.
Delete which id? abc
Please enter a whole number.
Delete which id? 1
Delete "Shopping"? (y/n): y
Deleted.
Bounding by 1 ..= store.next_id rather than 1 ..= len(store.notes) matters: next_id keeps climbing as notes are deleted and re-added, so a store with two notes left but a next_id of 7 (five earlier notes having been deleted along the way) still needs to accept an id like 6: the count of remaining notes is the wrong bound; find_note's own "no note with that id" message still catches an in-range-but-nonexistent id like 3 cleanly.
find_note's "not found" check is still there even though read_int_in_range already validated the range, that's not redundant. The range check confirms the number is plausible (a real id would fall in 1 ..= next_id); find_note confirms a note with that specific id still exists right now. A deleted note's old id is perfectly plausible and definitely not present.
11.4 Handling a corrupt save file
Chapter 8 and Chapter 10's load_notes already skip any line that doesn't decode: that's the "tolerant" half of tolerant loading. What's missing is telling the user anything happened at all. A file that's missing entirely is fine, expected, silent. A file that's present but has one garbled line in it is a different situation, and deserves a different response:
load_notes :: proc() -> Store {
store: Store
data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
if err != nil {
return store // missing file -- start empty, not a crash
}
defer delete(data)
skipped := 0
for line in strings.split_lines(string(data)) {
if len(line) == 0 {
continue
}
n, ok := decode_note(line)
if !ok {
skipped += 1
continue
}
append(&store.notes, n)
if n.id > store.next_id {
store.next_id = n.id
}
}
if skipped > 0 {
fmt.eprintfln("warning: skipped %d unreadable line(s) in %s", skipped, SAVE_FILE)
}
return store
}
Try it: save two well-formed notes, then hand-edit muninn_notes.txt and insert a line with no field separators in it at all.
$ printf '1\x1f1\x1fShopping\x1fMilk and eggs\nthis is garbage\n2\x1f2\x1fTodo\x1fWrite chapter 11\n' > muninn_notes.txt
$ odin run .
warning: skipped 1 unreadable line(s) in muninn_notes.txt
โโโโโโโโโโโโโโโโโโโโ
...
Both good notes load; the bad line is reported, not silently swallowed, and the program keeps going rather than refusing to start.
Gotcha: "file missing" and "file present but wrong" are different failures, and deserve different responses. A missing file (first run, or the file was deleted on purpose) is silent and expected: Chapter 8 already got that right. A present file with corrupt content is a sign something unexpected happened (a manual edit gone wrong, a crash mid-write in an earlier, less-careful version of a program), and staying silent about it hides real information the user might want.
err != nilfromread_entire_file_from_pathonly ever means "couldn't read this file at all" (missing, or a permissions problem); a line inside a file that did read successfully failing to decode is a completely separate signal, andskippedtracks it separately for exactly that reason.
11.5 Defensive boundaries, not defensive everywhere
Every validated read and every tolerant load in this chapter guards a boundary: user input arriving as free-typed text, or file content that something outside this program's control could have altered. find_note, list_notes, add_note don't re-validate their arguments the same way: a Note already sitting in store.notes is data this program built itself, following its own rules, and re-checking it on every access would be validating something that was never in doubt.
Why not validate everywhere, just to be safe? Because a check that can never fail isn't safety, it's noise: it costs a reader's attention understanding a branch that's dead code in disguise, and it costs you the discipline of knowing where your program's real boundaries are. This is the same house style I follow across every project in this family: trust your own internal invariants, and spend validation where untrusted input actually enters: the keyboard, and a file that could have been hand-edited between runs.
Checkpoint
Muninn no longer crashes on a bad menu keystroke, an out-of-range delete id, or a hand-corrupted save file: it complains once, sensibly, and keeps going either way. read_int_in_range replaced do_delete's ad-hoc parsing, and load_notes now tells you when it had to skip something instead of staying quiet about it.
Exercises
- Apply
read_int_in_rangesomewhere else it fits: if you built the5. Edit a notemenu item from Chapter 10's exercise 1, make it prompt for the id to edit the same waydo_deletenow does. - Rewrite
parse_twofrom ยง11.1 withoutor_return, using explicitif !ok { return 0, false }checks instead, and confirm both versions behave identically on("3", "4")and("3", "banana"). Which do you find more readable, and would your answer change for a procedure with five fallible steps instead of two? read_int_in_range's bounds check isn < lo || n > hi. What would happen (be specific about which menu action breaks and how) ifdo_deleteaccidentally called it withloandhiswapped (read_int_in_range(scanner, "...", store.next_id, 1))? Try it deliberately and read what happens before you reason about why.- Extend
load_notes's corrupt-file handling to also validate that a loaded note'sidisn't negative and itstitleisn't empty, incrementingskippedfor either. Hand-editmuninn_notes.txtto include a line with a valid shape but an empty title (two consecutiveFIELD_SEPcharacters) and confirm your check catches it. find_note's "not found" check in ยง11.3 runs afterread_int_in_rangealready confirmed the id is in range. Write down, in one or two sentences, a concrete id value that would pass the range check but still failfind_note: you don't need to run anything, just reason from hownext_idand deletion interact.
Further reading
- Odin,
or_returnand multiple return values: https://odin-lang.org/docs/overview/#or_return - Odin, FAQ (panics vs recoverable errors): https://odin-lang.org/docs/faq/