Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std.error — explicit mapping and contextual errors

Import std.error. A Context[E] records which operation failed and, when there is one, the path it acted on, around an unchanged typed cause E. The module is ordinary embedded Plenty code over Result and std.path; it adds no exceptions, implicit conversions, or runtime support.

pub enum Subject:
    Absent
    At(Path)
    Unavailable(AllocError)

pub class Context[E]:
    pub operation: str
    pub subject: Subject
    pub cause: E
OperationResultContract
map_error(result, mapper)Result[T, G]Consumes Result[T, E]; invokes an owned OnceCallable[[E], G] only on Err; same as the result.map_error(mapper) method
Context(operation, subject, cause)Context[E]Takes ownership of all three; allocates nothing
context(result, operation)Result[T, Context[E]]Wraps an Err with Subject.Absent; allocates nothing
path_context(result, operation, &path)Result[T, Context[E]]Wraps an Err, copying the path only then
error.describe()Result[str, AllocError]One-line text ending in str.repr of the cause
error.describe_with(&cause_text)Result[str, AllocError]The same text with a caller-supplied cause
error.format(&mut output)Result[(), FormatError]Borrowed std.report formatting when E satisfies Formattable
error.report()()Writes error: and the description to standard error
error.report_with(description)()The same for a description Result the caller built
native_code(error)Option[i32]The OS code of IoError.System(code); otherwise Nothing

context and path_context consume the Result and return Ok payloads unchanged, including () from operations such as flush_stdout() and File.close(). Conversion is always this explicit call: ? neither wraps an error in a Context nor unwraps one, and the identical-error rule applies to Context[E] as to any error type. A Context moves, because it can own a path. Its fields are public: read error.cause or match it to recover the typed cause, at any nesting depth. ? in a function returning Result[T, Failure], the last-resort erased error, discards a Context like any other error; list Context[E] in the sum to keep it.

from std.error import Context, path_context
from std.path import Path, from_text

def read_settings(path: &Path) -> Result[list[u8], Context[IoError]]:
    path_context(read_bytes(path.as_bytes()), "read settings", path)

def main() -> Result[(), AllocError | IoError]:
    path = from_text("settings.toml")?
    match read_settings(&path):
        case Ok(bytes):
            print(len(bytes), "bytes of settings")?
        case Err(error):
            # A missing settings file is expected: report it and use defaults.
            print(error.describe()?)?
            print(error.cause == IoError.System(2i32))?
            print("using default settings")?
    Ok(())
read settings "settings.toml": IoError.System(2)
True
using default settings

Explicit error mapping

result.map_error(mapper) consumes Result[T, E] and returns Result[T, G], where mapper is an owned OnceCallable[[E], G]. A one-payload enum variant is the usual mapper; use a named classifier when several error variants need different policies, or a closure to add other payloads or captured context:

i64.parse(text).map_error(ConfigError.Parse)?
print(line).map_error(def [path](cause: IoError) -> AppError: AppError.Write(path, cause))?

The library function map_error[T, E, G, F: OnceCallable[[E], G]](result: Result[T, E], mapper: F) has the same behavior; the method is a compiler-provided Result method, not an import. A class or enum that defines its own map_error keeps it.

The receiver and mapper expressions run once, receiver first, even on success. The mapper runs exactly once on Err, taking ownership of the original error; on Ok, the success payload moves through unchanged and the unused mapper is dropped. Named functions, reusable closures (including mutable environments), and consuming closures are accepted. Captures and affine payloads follow normal cleanup rules, including early return while constructing the mapper.

Mapping adds no allocation or boxing. Callback construction, execution, and destruction retain their ordinary effects. If the callback returns Result[G, X], the output is Result[T, Result[G, X]]: nothing is flattened and no cause-preservation policy is invented. Use context or path_context when their explicit context-allocation policy fits.

? still requires identical error types (except legacy Failure erasure); the conversion is visible at the map_error call. The mapper must be owned and take exactly the error type: a reference, a variant with several payloads, or a callable returning unit is rejected. .map_err(...) is not implemented. See the lesson.

Worker-reachable calls accept thin named or anonymous function values, including variant constructors such as ConfigError.Parse, when every function address of that callable signature in the program passes worker-effect certification. The compiler checks every candidate body and its stored types; one safe target never excuses an unsafe one. A main-thread-only address with the same signature can conservatively prevent certification. A concrete closure retains its own body and capture evidence; it cannot bypass the checks either.

Allocation failure

Attaching context never replaces the cause. path_context copies the borrowed path only on Err; if that copy fails, the context holds Subject.Unavailable(alloc_error) with the original operation and cause. To avoid the copy, move an owned path into Context(operation, Subject.At(path), cause). The Ok path of both helpers, construction, and field access allocate nothing.

describe allocates the cause’s representation, the escaped path, and intermediate strings, and returns AllocError if any step fails. The context is unchanged either way.

Description format

operation: cause
operation "escaped path": cause
operation <path unavailable>: cause

The path uses Path.display: printable ASCII is kept, backslash and double quote are escaped, and every other byte is \xhh, so a description is one line of ASCII apart from the operation and cause. describe renders the cause as str.repr does; a native code appears as IoError.System(2).

A nested context is a cause like any other, so describe shows its structural representation. Describe the inner context first and pass the text outward:

outer.describe_with(&outer.cause.describe()?)

That gives load settings: read_bytes "missing/settings": IoError.System(2). An application error enum can match its variants and call the method that fits each payload. std.fmt.Formattable provides borrowed formatting for application types. Context[E] supports this protocol when E does: it emits the operation and subject, then calls render on the typed cause exactly once. Nested contexts therefore produce nested operation labels without duplicating the cause. Each nested render counts toward the depth limit.

This conditional method is compiler-supported for the embedded std.error type; it does not introduce conditional methods for arbitrary generic classes. An unformattable cause keeps the existing description APIs and structural entrypoint reporting. describe and describe_with retain their existing behavior even when the cause has a format method.

Formatting an absent or unavailable subject adds no allocation. A present path uses the allocating Path.display; failure is returned as FormatError.Allocation(...), preserving the cause and any already-written prefix. Operation and cause text are emitted unchanged.

Reports

report flushes standard output, then writes error: , the description, and a newline to standard error in one write. It returns unit: flush and write failures are dropped, because standard error is the last channel. The caller keeps the error and chooses the exit status, normally by returning i32 from main.

Neither fallback allocates:

ConditionStandard error
The description cannot be allocatederror: operation: no memory to describe the error
The description exists but the whole line cannot be allocatedThe normal line, written in three pieces

report_with(description) applies the same rules to a description the caller built, such as the nested one above; pass the Result unhandled.

Returning Err(context) from main produces the entrypoint report. It selects the typed formatting method when the cause is formattable, otherwise renders the context structurally and shows a path as its byte list. The explicit report and report_with APIs keep their description-based behavior.

Conventions for standard modules

  1. An operation returns its precise error type: IoError, AllocError, or the module’s own enum. It does not attach context, format text, or allocate to describe a failure.
  2. A module enum that can fail from allocation has a variant carrying the AllocError, as WalkError.Allocation and DataError.Allocation do. Allocation failure is never folded into Other or Failure.
  3. A native code is kept as IoError.System(code) and never translated to text in the error value.
  4. A composite operation that must say which step or path failed returns Context[E], or an enum whose variants hold contexts. The operation label is a string literal naming the public operation, such as read_dir.
  5. Failing to allocate context reports the original cause, with Subject.Unavailable.
  6. Only an application decides to print. Library code returns errors; report belongs at the boundary that owns the exit status.

WalkError.Io predates this module and keeps its own (path, operation, cause) payload.

Limits

  • describe and report require a cause type str.repr accepts. A cause holding recursive data or a generator is rejected where one of them is first called for that Context[E].
  • No OS description text is added to a native code.
  • The legacy report and report_with methods use write_stderr: a pipe whose reader has gone terminates the process with SIGPIPE. The std.report formatter and entrypoint reporter suppress it and preserve normal cleanup.
  • A piecewise fallback report is not atomic against concurrent writers.
  • Subject has no network address or other non-path variant.

A Context[E] can cross threads when E can. Every operation takes time linear in the text it produces. Supported target: x86_64 Linux GNU. The lesson is Say which operation failed.

Acknowledgements

  • context and path_context are modelled on the Context trait of the Rust anyhow crate.