Error Handling
Dovetail takes a different approach to error handling than many mainstream languages. Instead of exceptions and try/catch blocks, Dovetail uses values to represent errors. This makes error handling explicit, composable, and checked by the compiler.
10.1 No Exceptions
Section titled “10.1 No Exceptions”In many languages, functions can throw exceptions at any point, and callers may or may not handle them. This leads to hidden control flow and runtime surprises.
Dovetail has no exceptions. Instead, it provides two types for representing the absence of a value or the possibility of failure:
Option<T>— a value that may or may not existResult<T, E>— an operation that may succeed withTor fail withE
Both are regular enum types defined in the prelude. There’s nothing magical about them — they follow the same rules as any other enum. The compiler ensures you handle both cases.
Benefits of errors-as-values:
- Explicit — the function signature tells you whether it can fail
- Composable — chain operations with methods like
map,andThen, andtry/orReturn - Compiler-checked — you can’t accidentally ignore an error case in a
match
10.2 Option Type
Section titled “10.2 Option Type”Definition
Section titled “Definition”Option<T> represents a value that may or may not be present:
public enum Option<T> = Some(T) NoneWhen to Use Option
Section titled “When to Use Option”Use Option when the absence of a value is a normal, expected situation — not an error:
- Looking up a key in a map
- Finding the first element that matches a condition
- Parsing an optional configuration field
Creating Option Values
Section titled “Creating Option Values”let found: Option<Int32> = Some(42)let missing: Option<Int32> = NonePattern Matching on Option
Section titled “Pattern Matching on Option”The most fundamental way to work with Option is pattern matching:
function describe(maybe: Option<Int32>): String = match maybe with case Some(value) => "Found: $value" case None => "Nothing here"Useful Methods
Section titled “Useful Methods”or — unwrap the value or use a default:
let x: Option<Int32> = Some(5)let y: Option<Int32> = None
assert x.or(0) == 5assert y.or(0) == 0expect — unwrap or panic with a message:
let x: Option<Int32> = Some(42)let value = x.expect("value must exist")assert value == 42require — unwrap or panic with a default message:
let x: Option<Int32> = Some(7)assert x.require == 7isSome / isNone — test which variant:
let x: Option<Int32> = Some(1)assert x.isSomeassert x.isNone == falsemap — transform the inner value if present:
let x: Option<Int32> = Some(3)let doubled = x.map(v => v * 2) // Some(6)
let y: Option<Int32> = Nonelet result = y.map(v => v * 2) // NoneandThen — chain operations that themselves return Option:
function half(x: Int32): Option<Int32> = if x % 2 == 0 then Some(x / 2) else None
let result = Some(10).andThen(half) // Some(5)let failed = Some(3).andThen(half) // Nonefilter — keep the value only if a predicate holds:
let x = Some(10).filter(v => v > 5) // Some(10)let y = Some(3).filter(v => v > 5) // NoneConverting: toResult
Section titled “Converting: toResult”Convert an Option to a Result by providing an error value for the None case:
let x: Option<Int32> = Some(42)let r = x.toResult("not found") // Ok(42)
let y: Option<Int32> = Nonelet s = y.toResult("not found") // Error("not found")10.3 Result Type
Section titled “10.3 Result Type”Definition
Section titled “Definition”Result<T, E> represents an operation that can succeed or fail:
public enum Result<T, E> = Ok(T) Error(E)When to Use Result
Section titled “When to Use Result”Use Result when an operation can fail and you want the caller to handle the failure:
- Parsing user input
- Reading from a file
- Validating data
- Network requests
Creating Result Values
Section titled “Creating Result Values”let success: Result<Int32, String> = Ok(42)let failure: Result<Int32, String> = Result.error("something went wrong")Ok(value) and Error(error) are variant constructors. The module also offers
Result.ok(value) and Result.error(error) helpers.
Pattern Matching on Result
Section titled “Pattern Matching on Result”function describe(result: Result<Int32, String>): String = match result with case Ok(value) => "Success: $value" case Error(msg) => "Failed: $msg"Useful Methods
Section titled “Useful Methods”or — unwrap the success value or use a default:
let ok: Result<Int32, String> = Ok(5)let err: Result<Int32, String> = Result.error("oops")
assert ok.or(0) == 5assert err.or(0) == 0expect — unwrap or panic with a message:
let r: Result<Int32, String> = Ok(42)assert r.expect("must succeed") == 42require — unwrap or panic with a default message:
let r: Result<Int32, String> = Ok(7)assert r.require == 7isOk / isError — test which variant:
let ok: Result<Int32, String> = Ok(1)assert ok.isOkassert ok.isError == falsemap — transform the success value:
let r: Result<Int32, String> = Ok(3)let doubled = r.map(v => v * 2) // Ok(6)mapError — transform the error value:
let r: Result<Int32, String> = Result.error("fail")let wrapped = r.mapError(e => "wrapped: $e") // Error("wrapped: fail")andThen — chain operations that return Result:
function parseAge(input: String): Result<Int32, String> = // ... parsing logic
function validateAge(age: Int32): Result<Int32, String> = if age >= 0 then Ok(age) else Result.error("age must be non-negative")
let result = parseAge("25").andThen(validateAge)Converting: toOption
Section titled “Converting: toOption”Convert a Result to an Option, discarding the error information:
let ok: Result<Int32, String> = Ok(42)assert ok.toOption().isSome // Some(42)
let err: Result<Int32, String> = Result.error("fail")assert err.toOption().isNone // None10.4 Railway-Oriented Programming
Section titled “10.4 Railway-Oriented Programming”The Problem
Section titled “The Problem”When chaining multiple operations that can fail, pattern matching quickly becomes deeply nested:
function processUser(id: Int32): Result<String, String> = match lookupUser(id) with case Error(e) => Result.error(e) case Ok(user) => match validateUser(user) with case Error(e) => Result.error(e) case Ok(valid) => match formatUser(valid) with case Error(e) => Result.error(e) case Ok(output) => Ok(output)This is sometimes called the “pyramid of doom.” Each level of nesting adds visual noise without adding meaning.
try and orReturn
Section titled “try and orReturn”Dovetail provides two equivalent syntactic forms to flatten this pattern:
try expr— prefix syntaxexpr.orReturn— postfix syntax
Both do the same thing: unwrap the success value, or early-return the error from the enclosing function.
The example above becomes:
function processUser(id: Int32): Result<String, String> = let user = try lookupUser(id) let valid = try validateUser(user) formatUser(valid)Or using the postfix form:
function processUser(id: Int32): Result<String, String> = let user = lookupUser(id).orReturn let valid = validateUser(user).orReturn formatUser(valid)How They Work
Section titled “How They Work”When you write try expr (or expr.orReturn):
- The expression is evaluated — it must produce an
OptionorResult - If it’s
Some(value)orOk(value), the value is unwrapped and execution continues - If it’s
NoneorError(e), the enclosing function immediately returns with the failure value
Return Type Compatibility
Section titled “Return Type Compatibility”The enclosing function’s return type must be compatible with the early return:
- If you
tryaResult<T, E>, the function must returnResult<_, E>(same error type) - If you
tryanOption<T>, the function must returnOption<_>
// Works: both return Result<_, String>function process(): Result<Int32, String> = let x = try getResult() // getResult(): Result<Int32, String> Ok(x + 1)
// Works: both return Option<_>function find(): Option<Int32> = let x = try lookup() // lookup(): Option<Int32> Some(x + 1)Chaining Multiple Operations
Section titled “Chaining Multiple Operations”You can freely mix try/orReturn with regular code in a single function:
function validateAndProcess(input: String): Result<Output, String> = let parsed = try parse(input) let validated = try validate(parsed) let enriched = enrich(validated) // this one can't fail let result = try save(enriched) Ok(result)Chaining After orReturn
Section titled “Chaining After orReturn”Because orReturn is a postfix operator, you can chain further operations directly on the unwrapped value — accessing tuple fields, calling methods, or accessing properties:
function getUser(): Result<(String, Int32), String> = Ok(("Alice", 30))
function getUserName(): Result<String, String> = let name = getUser().orReturn._0 Ok(name)function getConfig(): Result<Option<Int32>, String> = Ok(Some(42))
function process(): Result<Bool, String> = let hasSetting = getConfig().orReturn.isSome Ok(hasSetting)This is one of the advantages of the postfix orReturn form over the prefix try — it reads naturally left-to-right without needing parentheses.
Complete Example
Section titled “Complete Example”Here’s a multi-step validation pipeline:
function validateEmail(email: String): Result<String, String> = if email.length == 0 then Result.error("email cannot be empty") else if email.contains("@") == false then Result.error("email must contain @") else Ok(email)
function validateAge(age: Int32): Result<Int32, String> = if age < 0 then Result.error("age cannot be negative") else if age > 150 then Result.error("age is unrealistic") else Ok(age)
function createProfile(name: String, email: String, age: Int32): Result<Profile, String> = let validEmail = try validateEmail(email) let validAge = try validateAge(age) Ok(Profile { name = name; email = validEmail; age = validAge })If any validation fails, createProfile immediately returns the first error. No nesting required.
10.5 Panics and Assertions
Section titled “10.5 Panics and Assertions”The panic expression signals a defect. In synchronous application code it
terminates execution; tests can expect it with @panics. Async execution represents
defects through its cause channel, as described in Async Programming:
panic "something went terribly wrong"Panics are for truly unexpected situations — bugs, violated invariants, or states that “should never happen.” They are not for expected error conditions.
Assert
Section titled “Assert”The assert expression checks a condition and panics if it’s false:
assert x > 0assert list.length == expectedLengthAssertions are commonly used in tests and to check invariants during development.
When to Use Panics vs Result
Section titled “When to Use Panics vs Result”Use panic/assert when… |
Use Result when… |
|---|---|
| A bug has been detected | An operation can legitimately fail |
| An invariant is violated | User input might be invalid |
| “This should never happen” | A resource might be unavailable |
| In tests | The caller should decide how to handle failure |
expect and require — Bridging Option/Result to Panics
Section titled “expect and require — Bridging Option/Result to Panics”Sometimes you’re certain a value exists or an operation succeeded, and a missing value would be a bug. The expect and require methods let you unwrap with a panic:
// With a custom messagelet config = loadConfig().expect("config file must exist")
// With a default messagelet value = maybeValue.requireUse these sparingly. In production code, prefer or, map, or try/orReturn to handle failures gracefully. Reserve expect/require for cases where failure genuinely indicates a programming error.
Summary
Section titled “Summary”- Dovetail uses errors as values — no exceptions, no hidden control flow
Option<T>represents optional values (SomeorNone)Result<T, E>represents operations that can fail (OkorError)- Both types provide methods for safe access:
or,expect,require,isSome/isNone,isOk/isError mapandandThentransform values without unwrappingtry(prefix) andorReturn(postfix) enable railway-oriented programming — flat, readable chains of fallible operationstoResultandtoOptionconvert between the two typespanicandassertare for unrecoverable errors and invariant checkingexpectandrequirebridgeOption/Resultto panics when failure is a bug
Discarded results
Section titled “Discarded results”An intermediate expression normally discards its value. Dovetail warns when
that value is a Result, because a failure could go unnoticed:
parseNumber(text) // Warning if this returns Result()Handle the result, propagate it with try in a compatible function, or return
it. If ignoring either outcome is deliberate, write let _ = parseNumber(text).
The initializer still evaluates normally. This acknowledgement applies only to
the final value of the initializer;
intermediate discarded results inside it still warn.
These warnings appear during checking, compilation, and in the editor, and do not fail compilation. Type aliases retain the warning. Ordinary intermediate values, such as counters and builder chains, still need no acknowledgement. The check does not track whether a named binding is eventually used or inspect values hidden inside containers or erased types.
