Odin and Me ยท Chapter 11

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_range helper, replacing every ad-hoc retry loop since Chapter 3.
  • Make load_notes count 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_return is a genuine shortcut, and shortcuts are easiest to appreciate once you've felt the thing they're shortcutting. Every if !ok { ... } you've written by hand since Chapter 3 was real, useful practice with the pattern or_return now 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 != nil from read_entire_file_from_path only 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, and skipped tracks 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

  1. Apply read_int_in_range somewhere else it fits: if you built the 5. Edit a note menu item from Chapter 10's exercise 1, make it prompt for the id to edit the same way do_delete now does.
  2. Rewrite parse_two from ยง11.1 without or_return, using explicit if !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?
  3. read_int_in_range's bounds check is n < lo || n > hi. What would happen (be specific about which menu action breaks and how) if do_delete accidentally called it with lo and hi swapped (read_int_in_range(scanner, "...", store.next_id, 1))? Try it deliberately and read what happens before you reason about why.
  4. Extend load_notes's corrupt-file handling to also validate that a loaded note's id isn't negative and its title isn't empty, incrementing skipped for either. Hand-edit muninn_notes.txt to include a line with a valid shape but an empty title (two consecutive FIELD_SEP characters) and confirm your check catches it.
  5. find_note's "not found" check in ยง11.3 runs after read_int_in_range already 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 fail find_note: you don't need to run anything, just reason from how next_id and deletion interact.

Further reading