3. Errors: ?, ! and or { }
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: examples/l03_errors.v and examples/l03_pages.v — v run examples/l03_pages.v from code/v-for-csharp-java.
Two things V doesn’t have
Section titled “Two things V doesn’t have”V has no null and no exceptions. A function that may have no value returns an option, ?T; a function that may fail returns a result, !T. Both are part of the return type, and the compiler refuses to let you use the value without deciding what happens in the other case.
| Situation | C# | Java | V |
|---|---|---|---|
| A value may be absent | null, nullable types (warnings) |
null, Optional<T> |
?T, return none |
| An operation may fail | exceptions, all unchecked | checked and unchecked exceptions | !T, return error('…') |
| Handle it | ??, try/catch |
orElse, try/catch |
or { }, if x := f() { } |
| Pass it to the caller | nothing to write | throws in the signature |
! or ? after the call |
| A bug, the program stops | unhandled exception, Environment.FailFast |
unhandled exception, Error |
panic() |
Source: Option/Result types and error handling.
Options: ?T and none
Section titled “Options: ?T and none”const courses = ['duckdb', 'ladybugdb', 'rust-for-csharp-java', 'v-for-csharp-java']
fn find_course(prefix string) ?string { for c in courses { if c.starts_with(prefix) { return c } } return none}return c returns a string: V wraps it in the option. The caller can’t use the result as a string directly:
fn main() { name := find_course('rust') println(name.to_upper())}e03_option_in_expression.v:14:10: error: Option type `string` cannot be called directly, you should unwrap it first 12 | fn main() { 13 | name := find_course('rust') 14 | println(name.to_upper()) | ~~~~ 15 | }e03_option_in_expression.v:14:2: error: `println` can not print void expressions 12 | fn main() { 13 | name := find_course('rust') 14 | println(name.to_upper()) | ~~~~~~~~~~~~~~~~~~~~~~~~ 15 | }C# with nullable reference types would only warn (CS8602), and Java would compile and throw a NullPointerException at run time. Two ways to unwrap:
// or { } gives a default value, like ?? in C# or orElse in Javaprintln(find_course('lady') or { 'no course' })println(find_course('zig') or { 'no course' })
// if unwrapping: the variable only exists when there is a valueif c := find_course('rust') { println('found ${c}')}
// In an or block, `err` is the error; for an option it is `none`find_course('java') or { println('java: ${err}') }ladybugdbno coursefound rust-for-csharp-javajava: noneResults: !T and error()
Section titled “Results: !T and error()”strconv.atoi returns !int: an int, or an error where int.Parse would throw a FormatException and Integer.parseInt a NumberFormatException. A result must be handled at every call:
import strconv
fn main() { lines := strconv.atoi('474') println(lines)}e03_unhandled_result.v:4:19: error: strconv.atoi() returns `!int`, so it should have either an `or {}` block, or `!` at the end 2 | 3 | fn main() { 4 | lines := strconv.atoi('474') | ~~~~~~~~~~~ 5 | println(lines) 6 | }Even a function that returns nothing but can fail, ! alone, can’t be called and forgotten. os.write_file is one:
import os
fn main() { os.write_file('journal.md', '## 2026-09-14') println('saved')}e03_unhandled_void.v:4:5: error: os.write_file() returns `!void`, so it should have either an `or {}` block, or `!` at the end 2 | 3 | fn main() { 4 | os.write_file('journal.md', '## 2026-09-14') | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 5 | println('saved') 6 | }This is stricter than Java’s checked exceptions, which only apply to the exceptions a method declares: in V, every call that may fail says so.
Four ways to handle a result
Section titled “Four ways to handle a result”// 1. ! passes the error to the caller, which must itself return a resultfn parse_lines(field string) !int { n := strconv.atoi(field)! if n < 0 { return error('negative line count: ${n}') } return n}
fn total_lines(fields []string) !int { mut total := 0 for f in fields { total += parse_lines(f)! } return total}// 2. A default valueprintln(parse_lines('474') or { -1 })// 3. Do something with `err`parse_lines('12abc') or { println(err) }parse_lines('-3') or { println(err) }// 4. Leave the loop, or the function, from the or blockfor fields in [['474', '341'], ['474', 'x', '341']] { total := total_lines(fields) or { println('${fields}: ${err}') continue } println('${fields}: ${total} lines')}474strconv.atoi: parsing "12abc": invalid radix 10 characternegative line count: -3['474', '341']: 815 lines['474', 'x', '341']: strconv.atoi: parsing "x": invalid radix 10 characterif x := f() { } else { } works for results too, with err in the else branch. error() creates an error from a message; error_with_code() adds a number.
! after a call is the visible equivalent of letting an exception propagate. It needs a function that returns a result:
import strconv
fn parse_lines(field string) int { return strconv.atoi(field)!}e03_propagate_without_result.v:4:28: error: to propagate the Result call, `parse_lines` must return a Result 2 | 3 | fn parse_lines(field string) int { 4 | return strconv.atoi(field)! | ^ 5 | } 6 |Details: e03_propagate_without_result.v:3:30: details: prepend ! before the declaration of the return type of `parse_lines` 1 | import strconv 2 | 3 | fn parse_lines(field string) int { | ~~~ 4 | return strconv.atoi(field)! 5 | }For an option, the same operator is ?: dot := title.index('. ')? returns none from the current function when there is no match.
Panics
Section titled “Panics”A panic stops the program: it prints V panic: and a message, then a backtrace, and exits with code 1. There is no catch.
An array index out of range panics, like an IndexOutOfRangeException that nothing catches:
fn main() { courses := ['duckdb', 'ladybugdb'] i := 2 println(courses[i])}V panic: array.get: index out of range (i,a.len):2, 2or { } after an index turns the panic into a value: courses[10] or { 'index out of range' }.
! in main, which can’t pass the error further, panics:
import strconv
fn main() { lines := strconv.atoi('forty-two')! println(lines)}V panic: result not set (strconv.atoi: parsing "forty-two": invalid radix 10 character)or { panic(err) } does the same with the error’s own message:
import os
fn main() { text := os.read_file('missing.csv') or { panic(err) } println(text)}V panic: failed to open file "missing.csv"; code: 2The first line is the same on the three OSes. On Windows, the lines that follow give the commit of V, the process and thread IDs, and a backtrace in the generated C file, such as C://Users//spare//AppData//Local//Temp//v_0//P03_OR~1.C:4689: at builtin___v_panic: Backtrace; its content depends on the OS and the C compiler, and the course’s CI only compares the first line.
Custom error types
Section titled “Custom error types”An error is any type that implements the IError interface, with msg() string and code() int. Embedding the built-in Error struct provides both, and you override what you need:
struct ParseError { Error line_no int reason string}
fn (e ParseError) msg() string { return 'line ${e.line_no}: ${e.reason}'}
fn (e ParseError) code() int { return e.line_no}data/pages.csv has one line per page of this site. A first parser splits each line on commas, and returns a ParseError when a line doesn’t have five fields:
fn parse_page(line_no int, line string) !Page { fields := line.split(',') if fields.len != 5 { return ParseError{ line_no: line_no reason: 'expected 5 fields, got ${fields.len}' } } lines := strconv.atoi(fields[4]) or { return ParseError{ line_no: line_no reason: err.msg() } } return Page{fields[0], fields[1], fields[2], fields[3], lines}}return ParseError{…} in a function that returns !Page is an error, not a page: V converts the struct to IError. The caller collects the errors, then tests their type with is, the equivalent of catch (ParseError e):
lines := os.read_lines('data/pages.csv')!mut pages := []Page{}mut errors := []IError{}for i, line in lines[1..] { page := parse_page(i + 2, line) or { errors << err continue } pages << page}println('naive split: ${pages.len} pages, ${errors.len} errors')for e in errors[..3] { if e is ParseError { println(' code ${e.code()}: ${e.msg()}') }}naive split: 269 pages, 50 errors code 10: line 10: expected 5 fields, got 6 code 21: line 21: expected 5 fields, got 6 code 26: line 26: expected 5 fields, got 6Inside if e is ParseError, e is a ParseError with its line_no field: V casts it automatically, like a pattern if (e is ParseError pe) in C#. Line 10 of the file shows why the naive parser fails:
/duckdb/08-persistence/,en,duckdb,"8. Persistence, transactions and concurrency",394The title is quoted because it contains commas. encoding.csv handles quotes; its reader returns ![]string for each row, and an error at the end of the file:
mut reader := csv.new_reader(os.read_file('data/pages.csv')!)reader.read()! // the headermut parsed := []Page{}for { row := reader.read() or { if err is csv.EndOfFileError { break } panic(err) } parsed << Page{row[0], row[1], row[2], row[3], strconv.atoi(row[4])!}}encoding.csv: 319 pages 8. Persistence, transactions and concurrency: 394 linesOne trap with a smart-cast error: ${err} then prints the struct with all its fields, not msg(), so call err.msg() explicitly (see the journal).
csv.EndOfFileError is declared without pub in reader.v, lines 25-31, yet V 0.5.2 accepts it after is in another module. Reaching the end of the file is reported as an error, like Go’s io.EOF, not as an option.
Key takeaways
Section titled “Key takeaways”?Tis a value ornone;!Tis a value or an error. The compiler refuses a call whose option or result isn’t handled, even for a function that returns nothing.or { }gives a default value, or leaves withreturn,continue,breakorpanic;if x := f() { }unwraps;!and?pass the error or thenoneto the caller.string.int()and its siblings never fail: they return 0 or saturate. Parse untrusted input withstrconv.- A panic stops the program with code 1, and can’t be caught. An index out of range panics unless it has
or { }. - A custom error embeds
Error, overridesmsg()andcode(), and is recognized withis.
Exercises
Section titled “Exercises”- Write
fn lesson_number(title string) ?int, which returns the number before'. 'in a title such as'8. Persistence, transactions and concurrency', andnonefor'Mission'or'v2. Draft'.string.indexreturns?int.
Solution
fn lesson_number(title string) ?int { dot := title.index('. ')? return strconv.atoi(title[..dot]) or { return none }}8: 8. Persistence, transactions and concurrency-: Mission-: v2. Draft12: 12. Cross-compilation? passes on the none of index. strconv.atoi returns a result, not an option: or { return none } turns its error into none, and 'v2' gives none.
- Change
total_linesso that its error says which field is wrong:field 2: strconv.atoi: parsing "x": invalid radix 10 character.
Solution
fn total_lines(fields []string) !int { mut total := 0 for i, f in fields { total += strconv.atoi(f) or { return error('field ${i + 1}: ${err.msg()}') } } return total}815field 2: strconv.atoi: parsing "x": invalid radix 10 characterreturn in an or block leaves the function with a new error, which wraps the message of the original one: the equivalent of throwing a new exception with the original one as its inner exception, without the chain.
- A colleague reads the
linescolumn withrow[4].int(), and a line saysn/a. What happens in V, and what would happen withint.Parsein C# andInteger.parseIntin Java?
Solution
In V, 'n/a'.int() returns 0: the page counts zero lines and nothing reports the problem. int.Parse("n/a") throws a FormatException, and Integer.parseInt("n/a") a NumberFormatException. strconv.atoi(row[4]) restores an error that must be handled. Lesson 4 still uses row[4].int() on pages.csv, a file whose lines column this lesson’s parser has checked.