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)
}