Error Handling Patterns in V
V is a systems programming language that takes a deliberate stance on error handling: it does not use exceptions. Instead, V relies on explicit, type‑safe mechanisms built around option types and result types. This guide explores the core error handling patterns in V, from the basic or block to advanced techniques like custom error types and error wrapping. You’ll learn what these patterns are, why they matter, how to apply them in real code, and the best practices that keep V software robust and maintainable.
What Is Error Handling in V?
Error handling in V is the discipline of anticipating and managing failures without hidden control flow. Every function that can fail must declare its failure possibility in the signature, and every caller must explicitly decide how to handle that possibility. The language provides three complementary tools:
- Option types (
?T) – represent a value that may be absent (none) or present. - Result types (
!T) – represent either a successful value or an error. - The
orblock – a dedicated syntax for handling absent values and errors inline.
Together they form a predictable, composable error model that eliminates surprises like unchecked exceptions.
Why Error Handling Matters in V
In many languages, errors can be ignored by accident—exceptions can fly up the stack unnoticed, or error‑return codes can be silently dropped. V’s model matters because it:
- Forces explicit handling – you cannot call a fallible function and ignore its error; the compiler verifies that every error path is addressed.
- Makes failures visible – the
!Tor?Tin a function signature immediately communicates “this can fail” to anyone reading the code. - Prevents cascading crashes – errors are caught at the call site, not in a distant, unrelated handler.
- Encourages thoughtful recovery – developers are guided to decide whether to propagate, provide a default, or take corrective action.
Adopting V’s error handling patterns leads to codebases where failures are a first‑class part of the design, not an afterthought.
How to Use Error Handling in V
The Option Type and the or Block
An option type ?T indicates that a value of type T might be none. The or block lets you specify what to do when that absence occurs. It can return a default value, log a message, or even change the control flow.
// Function returning an optional index
fn find_element(arr []int, target int) ?int {
for i, val in arr {
if val == target {
return i
}
}
return none
}
fn main() {
numbers := [10, 20, 30]
// Use `or` to supply a default value
idx := find_element(numbers, 25) or { -1 }
println('Index: $idx') // prints -1
// Use `or` to handle the missing case with more logic
idx2 := find_element(numbers, 20) or {
println('Element not found – aborting')
return
}
println('Found at index $idx2')
}
In the first call, the or block returns -1 directly. In the second, the block contains a full statement that prints a message and exits the function. The compiler ensures you always attach an or block (or propagate the option) when unwrapping an option type.
The Result Type (!T)
While options signal absence, result types signal failure with an error object. A function returning !T must either return a valid T or an error. The or block again catches the error case, and inside it you can access the error via the special variable err.
import os
fn read_config(path string) !string {
content := os.read_file(path) or {
// os.read_file returns !string; its or block creates a new error
return error('could not read config file: $path')
}
return content
}
fn main() {
config := read_config('app.conf') or {
println('Error loading config: $err.msg()')
return
}
println('Config loaded: $config')
}
The error() built‑in creates a generic error with a message. The err variable inside the or block gives access to the original error object, allowing inspection through err.msg() (every error must implement the msg() method).
Error Propagation with ?
When a function itself returns a result type, you can propagate errors upward using the ? suffix operator. It unwraps the value if successful, or returns the error immediately from the enclosing function. This keeps error handling concise while maintaining full traceability.
fn process() !string {
// Propagates any error from read_config automatically
config := read_config('app.conf')?
// If we reach here, config is guaranteed to be a string
return config.to_upper()
}
fn main() {
result := process() or {
println('Processing failed: $err.msg()')
return
}
println('Result: $result')
}
The ? operator works only inside functions that return !T (or ?T). It is the recommended way to bubble errors up without manual if checks.
Patterns in Practice
Pattern 1: Using or for Default Values
When a missing value has a sensible fallback, inline the default directly in the or block. This pattern reduces noise and keeps the intent clear.
fn get_port() ?int {
// Simulate: port may not be configured
return none
}
fn main() {
port := get_port() or { 8080 }
println('Using port $port') // 8080
}
The or block here is a single expression; no extra logic needed.
Pattern 2: Custom Error Types
For domain‑specific failures, define your own error structs that implement the msg() method. This allows callers to distinguish different error conditions with match and attach relevant metadata.
struct ValidationError {
msg string
field string
}
fn (e ValidationError) msg() string {
return 'validation error on field `${e.field}`: ${e.msg}'
}
fn validate_age(age int) !int {
if age < 0 {
return ValidationError{
msg: 'age must be positive',
field: 'age'
}
}
if age > 150 {
return ValidationError{
msg: 'age seems unrealistic',
field: 'age'
}
}
return age
}
fn main() {
age := validate_age(-5) or {
match err {
ValidationError {
println('Invalid field $err.field: $err.msg')
return
}
else {
println('Unknown error: $err.msg()')
return
}
}
}
println('Age: $age')
}
Custom error types make error handling precise. The match block inspects the concrete error and reacts accordingly, while the else branch catches any unexpected error.
Pattern 3: Error Wrapping and Context
When a low‑level error occurs, wrap it with additional context before propagating. This builds a clear error trail that helps debugging.
fn load_data() !string {
file := 'data.txt'
content := os.read_file(file) or {
// Wrap the original error with more context
return error('failed to load $file: $err.msg()')
}
return content
}
fn parse_data(content string) !int {
// Simulate a parse that can fail
return error('invalid format')
}
fn process_data() !int {
data := load_data()?
value := parse_data(data) or {
return error('processing interrupted: $err.msg()')
}
return value
}
Each wrapping step preserves the root cause while adding a human‑readable layer. The final error message becomes a chain: “processing interrupted: failed to load data.txt: permission denied” (if os.read_file reported that).
Pattern 4: Exhaustive Error Handling with match
When a function can produce multiple distinct errors, use match on the error object to handle each case separately. This guarantees that every known error path is addressed.
struct IOError {
msg string
}
fn (e IOError) msg() string { return e.msg }
struct ParseError {
msg string
line int
}
fn (e ParseError) msg() string {
return 'parse error at line $e.line: $e.msg'
}
fn risky_operation() !string {
// Could fail with different error types
return IOError{msg: 'disk full'}
}
fn main() {
result := risky_operation() or {
match err {
IOError {
println('I/O failure, retrying...')
// actual retry logic here
}
ParseError {
println('Bad data at line $err.line: $err.msg')
}
else {
println('Unexpected error: $err.msg()')
}
}
return
}
println('Success: $result')
}
The compiler ensures the match covers all possible error types known at compile time if you list them explicitly, otherwise the else branch acts as a catch‑all. This pattern is especially useful in libraries that expose a rich error taxonomy.
Best Practices
- Always handle errors explicitly. Never discard an error with
_unless you are absolutely certain it is irrelevant (and document that choice). - Use
orblocks for defaults when a fallback value makes sense. For critical operations, propagate the error instead of hiding it behind a default. - Prefer
?propagation in functions that return!Tor?T. It keeps the code linear and avoids nestedorblocks. - Create custom error types for domain‑specific failures. This lets callers differentiate and recover precisely.
- Wrap errors with context. Use
error('context: $err.msg()')to preserve the original cause while adding information about the higher‑level operation. - Use
matchto exhaustively handle known error variants. This prevents silent fallbacks and makes error handling self‑documenting. - Keep error messages descriptive. Include variable names, file paths, or reasons that help diagnose the problem later.
- Leverage the compiler. V’s type system will refuse to compile if you forget to handle a result or option. Treat compiler errors as a safety net, not an annoyance.
Conclusion
V’s error handling patterns replace hidden exceptions with a transparent, type‑driven model. By combining option types, result types, the or block, and the ? operator, you can express failure handling that is both concise and exhaustive. Whether you supply a default value, propagate an error, define custom error types, or wrap context for better debugging, the language provides consistent tools that integrate seamlessly into everyday code. Following the best practices outlined here will help you write V programs that are predictable under failure, easy to reason about, and a pleasure to maintain.