← Back to DevBytes

Error Handling Patterns in V

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles