Error Handling
BioLang provides two complementary approaches to error handling: the Result
and Option types for explicit, type-safe error propagation, and
try/catch for exception-style handling. The ?
operator enables concise error propagation within functions that return Result.
The Result Type
Result[T] represents either a successful value (Ok(T)) or an
error (Err(Error)). Functions that can fail should return Result:
# Functions that return Result
fn parse_vcf_line(line: String) -> Result[Variant] {
let fields = line |> split("\t")
if len(fields) < 8 {
return Err("VCF line has fewer than 8 fields")
}
let chrom = fields[0]
let pos = int(fields[1])? # ? propagates error if int() fails
let qual = float(fields[5])?
Ok(Variant {
chrom: chrom,
pos: pos,
ref_allele: fields[3],
alt_allele: fields[4],
qual: qual
})
}
The ? Operator
The ? operator is the primary tool for error propagation. When applied to a
Result, it unwraps the Ok value or immediately returns the
Err from the enclosing function:
# Without ? — verbose
fn load_and_filter(path: String) -> Result[Table] {
let content = read_text(path)
match content {
Err(e) => return Err(e),
Ok(text) => {
let parsed = csv(text)
match parsed {
Err(e) => return Err(e),
Ok(table) => Ok(table |> filter(|r| r.score > 0.5))
}
}
}
}
# With ? — concise and clear
fn load_and_filter(path: String) -> Result[Table] {
let text = read_text(path)?
let table = csv(text)?
Ok(table |> filter(|r| r.score > 0.5))
}
# Chain ? in pipes
fn process_sample(path: String) -> Result[Table] {
let data = csv(path)?
let validated = validate_schema(data)?
let normalized = normalize(validated)?
Ok(normalized)
}
The Option Type
Option[T] represents a value that may be absent: Some(T) or
None. Use it when absence is a normal condition, not an error:
# Functions returning Option
fn find_gene(name: String, database: List[Gene]) -> Option[Gene] {
database |> find(|g| g.symbol == name)
}
# Working with Option values
let gene = find_gene("BRCA1", db)
# unwrap — panics if None
let g = gene |> unwrap()
# unwrap_or — provide a default
let g = gene |> unwrap_or(default_gene)
# unwrap_or_else — compute default lazily
let g = gene |> unwrap_or_else(|| fetch_from_ncbi("BRCA1"))
# map — transform the inner value if present
let name = gene |> map(|g| g.symbol) # Option[String]
# and_then — chain Option-returning operations
let pathway = gene
|> and_then(|g| find_pathway(g.id))
|> and_then(|p| lookup_drugs(p.name))
Pattern Matching on Result and Option
# Match on Result
let message = match load_data("samples.csv") {
Ok(data) => f"Loaded {data.num_rows} rows",
Err(e) => f"Failed to load: {e.message}"
}
# Match on Option
match find_gene("TP53", db) {
Some(gene) => {
print(f"Found {gene.symbol} on {gene.chrom}")
analyze(gene)
},
None => {
print("Gene not found in database")
}
}
# Nested Result/Option matching
match fetch_annotation(variant_id) {
Ok(Some(ann)) => print(f"Annotation: {ann.description}"),
Ok(None) => print("No annotation available"),
Err(e) => print(f"Fetch failed: {e}")
}
Try / Catch
For top-level scripts and situations where exception-style handling is clearer, BioLang
provides try/catch blocks:
# Basic try/catch
try {
let data = csv("input.csv")?
let results = analyze(data)?
write_csv(results, "output.csv")?
print("Analysis complete")
} catch e {
print(f"Pipeline failed: {e.message}")
print(f" at: {e.location}")
}
# try/catch with specific error types
try {
let variants = read_vcf("variants.vcf")?
let annotated = annotate(variants, "clinvar.vcf")?
write_vcf(annotated, "annotated.vcf")?
} catch IoError as e {
print(f"File error: {e.path} — {e.message}")
} catch ParseError as e {
print(f"Parse error at line {e.line}: {e.message}")
} catch e {
print(f"Unexpected error: {e}")
}
Try as an Expression
try can be used as an expression that converts exceptions into
Result values:
# try expression returns Result
let result: Result[Table] = try { csv("data.csv") }
# Useful in map for fault-tolerant batch processing
let results = file_paths
|> map(|path| try { csv(path) })
|> filter(|r| is_ok(r))
|> map(|r| unwrap(r))
# With a default value
let data = try { csv("data.csv") }
|> unwrap_or(empty_table())
Custom Error Types
# Define custom error with struct
struct PipelineError {
stage: String,
message: String,
sample_id: String
}
# Use in functions
fn run_qc(sample: Sample) -> Result[QcReport] {
let stats = try { compute_stats(sample) }
|> map_err(|e| PipelineError {
stage: "quality_control",
message: e.message,
sample_id: sample.id
})?
if stats.mean_quality < 15.0 {
return Err(PipelineError {
stage: "quality_control",
message: f"Mean quality below threshold: {stats.mean_quality}",
sample_id: sample.id
})
}
Ok(QcReport { sample: sample.id, stats: stats })
}
Error Context
Add context to errors as they propagate up the call stack using context:
fn process_sample(sample_id: String) -> Result[Report] {
let reads = read_fastq(f"{sample_id}.fq.gz")
|> context(f"reading FASTQ for sample {sample_id}")?
let aligned = align(reads, reference)
|> context(f"aligning sample {sample_id}")?
let variants = call_variants(aligned)
|> context(f"calling variants for sample {sample_id}")?
Ok(generate_report(variants))
}
# Error message includes full context chain:
# "calling variants for sample S001: GATK exited with code 1"
Retry Logic
# Retry with exponential backoff
fn fetch_with_retry(url: String, max_attempts = 3) -> Result[String] {
let attempt = 0
let last_error = None
while attempt < max_attempts {
match try { fetch(url) } {
Ok(response) => return Ok(response),
Err(e) => {
last_error = Some(e)
attempt = attempt + 1
if attempt < max_attempts {
sleep(pow(2, attempt) * 1000) # Exponential backoff
}
}
}
}
Err(f"Failed after {max_attempts} attempts: {unwrap(last_error)}")
}
# Usage
let seq = fetch_with_retry("https://api.ncbi.nlm.nih.gov/seq/NM_007294")?
Retry
The retry expression re-attempts a block up to N times with an
optional delay — essential for flaky network calls to bio databases:
# Retry NCBI fetch 3 times with 2-second delay
let gene = retry(3, delay: 2000) {
ncbi_gene("TP53")
}
# Retry with increasing delay (manual exponential backoff)
let result = retry(5, delay: 1000) {
ensembl_vep("rs699")
}
Collecting Results
# Process all, collect successes and failures separately
let results = samples
|> map(|s| (s.id, try { process(s) }))
let successes = results
|> filter(|(_, r)| is_ok(r))
|> map(|(id, r)| (id, unwrap(r)))
let failures = results
|> filter(|(_, r)| is_err(r))
|> map(|(id, r)| (id, unwrap_err(r)))
print(f"Succeeded: {len(successes)}, Failed: {len(failures)}")
for (id, err) in failures {
print(f" {id}: {err.message}")
}
# collect_results — stops at first error
let all_ok: Result[List[Table]] = paths
|> map(|p| csv(p))
|> collect_results() # Err if any failed, Ok(list) if all succeeded
Assertions
# assert — panics with message if condition is false
assert(len(reads) > 0, "No reads found in input file")
assert(len(reference) > 0, "Reference sequence is empty")
# assert with equality checks
assert(len(columns) == expected_cols, "Column count mismatch")
assert(sample_a != sample_b, "Samples must be different")
# Use in validation functions
fn validate_vcf(table: Table) -> Result[Table] {
if !("CHROM" in table.columns) {
return Err("Missing required column: CHROM")
}
if !("POS" in table.columns) {
return Err("Missing required column: POS")
}
if table.num_rows == 0 {
return Err("VCF table is empty")
}
Ok(table)
}