Packages

Error handling utilities with support for error kinds, context, and sources.

Current section

Files

Jump to
glerror src glerror.gleam
Raw

src/glerror.gleam

//// Error handling utilities with support for error kinds, context, and sources.
import glam/doc
import gleam
import gleam/int
import gleam/list
import gleam/result
import gleam/string
/// A generic error type that supports error kinds, context layers, and source errors.
///
/// The `kind` parameter allows you to specify custom error variants for type-safe
/// error handling. Use `Error` (which is `KindError(String)`) for simple ad-hoc errors.
///
/// The source is stored as a lazy function to avoid unnecessary string construction
/// when the error is not displayed.
pub opaque type KindError(kind) {
KindError(
kind: Kind(kind),
context: List(fn() -> String),
sources: List(Format),
)
}
pub type Kind(kind) {
Kind(kind: kind, format: Format)
}
pub fn to_kind(kind: kind) -> Kind(kind) {
Kind(kind: kind, format: inspect_format(kind))
}
/// Controls how much detail a `Format` function should produce.
pub type Detail {
/// The expanded, hierarchical rendering (see `pretty_print`). Context and
/// sources are broken out onto their own lines, roughly:
///
/// ```text
/// error: Not found
/// context:
/// 0: fetching user
/// sources:
/// ╰─▶ HTTP 404
/// ```
High
/// The compact, single-line rendering (see `to_string`). Context and
/// sources are folded into one line, roughly:
///
/// ```text
/// Not found [fetching user] (source: HTTP 404)
/// ```
Low
}
/// The arguments passed to a `Format` function when it is rendered.
///
/// Carries the requested `detail` level, which a `Format` inspects to decide
/// how much to render.
pub type FormatArgs {
FormatArgs(detail: Detail)
}
/// A lazy, detail-aware renderer for a value such as an error kind or source.
///
/// A `Format` is a function from `FormatArgs` to a `String`. It is kept lazy
/// so that the string is only built when the error is actually displayed, and
/// it receives the requested `Detail` level so the same value can render
/// compactly (`Low`) or with full hierarchy (`High`).
///
/// Construct one with `string_format` (a fixed string), `inspect_format`
/// (via `string.inspect`), or `as_format` (render another `KindError`, which
/// is how nested errors are embedded as sources). You can also write your own:
///
/// ```gleam
/// import glerror as error
///
/// let format: error.Format = fn(args) {
/// case args.detail {
/// error.High -> "a long, detailed message"
/// error.Low -> "short message"
/// }
/// }
/// ```
pub type Format =
fn(FormatArgs) -> String
/// Formats a term using string.inspect.
pub fn inspect_format(term: anything) -> Format {
fn(_) -> String { string.inspect(term) }
}
pub fn string_format(s: String) -> Format {
fn(_) { s }
}
pub fn as_format(error: KindError(kind)) -> Format {
fn(args: FormatArgs) -> String {
case args.detail {
High -> pretty_print(error)
Low -> to_string(error)
}
}
}
/// A convenient error type for simple string-based errors.
///
/// This is the recommended type for most use cases where you don't need
/// to pattern match on specific error variants.
pub type Error =
KindError(String)
/// A convenient Result type alias for simple errors.
///
/// Use this when you want `Result(value, Error)` without repeating the error type.
pub type Result(value) =
gleam.Result(value, Error)
// ---- Construction ----
/// Create a new error from a custom error kind.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// pub type MyError {
/// NotFound
/// InvalidInput
/// }
///
/// let err = error.new(error.to_kind(NotFound))
/// ```
pub fn new(kind: Kind(kind)) -> KindError(kind) {
KindError(kind: kind, context: [], sources: [])
}
/// Create a simple string-based error.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
/// let err = error.error("File not found")
/// ```
pub fn error(message: String) -> Error {
new(Kind(message, string_format(message)))
}
// ---- Modification ----
/// Add a context layer to an error.
///
/// Context is added in a stack, with the most recent context appearing first
/// when the error is displayed.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
/// error.error("connection refused")
/// |> error.add_context("connecting to database")
/// |> error.add_context("initializing application")
/// ```
pub fn add_context(err: KindError(kind), context: String) -> KindError(kind) {
KindError(..err, context: [fn() { context }, ..err.context])
}
pub fn add_context_fn(
err: KindError(kind),
context: fn() -> String,
) -> KindError(kind) {
KindError(..err, context: [context, ..err.context])
}
/// Attach a source error description.
///
/// This is useful when you want to include information about the underlying
/// cause of the error without exposing the actual error type.
///
/// The source is stored as a lazy function and only evaluated when the error
/// is displayed, avoiding unnecessary string construction.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
/// error.error("Database query failed")
/// |> error.with_source(fn() { "Connection timeout after 30s" })
///
/// // With dynamic data
/// error.error("Database query failed")
/// |> error.with_source(fn() {
/// "Connection timeout at " <> format_timestamp(now())
/// })
/// ```
pub fn with_source(err: KindError(kind), source: Format) -> KindError(kind) {
KindError(..err, sources: list.append(err.sources, [source]))
}
/// Transform the error kind using a mapping function.
///
/// This is useful when converting between different error types.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// pub type HttpError {
/// NotFound
/// ServerError
/// }
///
/// pub type AppError {
/// HttpFailed(HttpError)
/// ParseFailed
/// }
///
/// let http_err = error.new(error.to_kind(NotFound))
/// let app_err = error.map_kind(http_err, fn(e) { error.to_kind(HttpFailed(e)) })
/// ```
pub fn map_kind(
err: KindError(kind),
mapper: fn(kind) -> Kind(new_kind),
) -> KindError(new_kind) {
KindError(
kind: mapper(err.kind.kind),
context: err.context,
sources: err.sources,
)
}
// ---- Result Helpers ----
/// Add context to a Result if it's an error.
///
/// This is the primary way to add context when working with Result types.
/// The Ok value passes through unchanged.
///
/// ## Example
///
/// ```gleam
/// parse_json(data)
/// |> glerror.context("parsing user data")
/// |> glerror.context("loading profile")
/// ```
pub fn context(
res: gleam.Result(value, KindError(kind)),
context: String,
) -> gleam.Result(value, KindError(kind)) {
result.map_error(res, add_context(_, context))
}
/// Add context to a Result using a lazy function.
///
/// The function is only called if the Result is an Error, avoiding
/// unnecessary string construction in the success case.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// fetch_user(user_id)
/// |> error.lazy_context(fn() {
/// "fetching user " <> int.to_string(user_id)
/// })
/// ```
pub fn lazy_context(
res: gleam.Result(value, KindError(kind)),
context: fn() -> String,
) -> gleam.Result(value, KindError(kind)) {
case res {
Ok(_) -> res
Error(err) -> Error(add_context(err, context()))
}
}
/// Convert a Result with a different error type to use KindError.
///
/// This is useful when integrating with libraries that use their own error types.
///
/// ## Example
///
/// ```gleam
/// int.parse("42")
/// |> error.from_result(
/// Kind("Invalid integer", fn(_) { "Invalid integer" }),
/// fn(_) { fn(_) { "Parse error" } }
/// )
/// ```
pub fn from_result(
res: gleam.Result(value, original_error),
kind: Kind(kind),
format_source: fn(original_error) -> Format,
) -> gleam.Result(value, KindError(kind)) {
case res {
Ok(value) -> Ok(value)
Error(err) ->
Error(
new(kind)
|> with_source(format_source(err)),
)
}
}
// ---- Accessors ----
/// Get the error kind from a KindError.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// let err = error.new(error.to_kind(NotFound))
/// error.kind(err) // NotFound
/// ```
pub fn kind(err: KindError(kind)) -> kind {
err.kind.kind
}
// ---- Display ----
/// Convert a KindError to a single-line string representation.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// error.error("Database connection failed")
/// |> error.add_context("loading user")
/// |> error.add_context("fetching profile")
/// |> error.to_string
/// // "Database connection failed [loading user, fetching profile]"
/// ```
pub fn to_string(err: KindError(kind)) -> String {
let base = err.kind.format(FormatArgs(detail: Low))
let with_context = case err.context {
[] -> base
context -> {
let context_str =
context
|> list.reverse
|> list.map(fn(c) { c() })
|> string.join(", ")
base <> " [" <> context_str <> "]"
}
}
case err.sources {
[] -> with_context
sources -> {
let sources_str =
sources
|> list.map(fn(s) { s(FormatArgs(detail: Low)) })
|> string.join(", ")
let sources_title = case list.length(sources) {
1 -> "source"
_ -> "sources"
}
with_context <> " (" <> sources_title <> ": " <> sources_str <> ")"
}
}
}
/// Format a KindError with hierarchical pretty printing.
///
/// This produces a tree-like output with numbered context sections and
/// box-drawing characters for sources, supporting nested errors via
/// `as_format`.
///
/// ## Example
///
/// ```gleam
/// import glerror as error
///
/// error.error("Not found")
/// |> error.add_context("fetching user")
/// |> error.add_context("loading dashboard")
/// |> error.with_source(fn(_) { "HTTP 404" })
/// |> error.pretty_print
/// ```
///
/// Output:
/// ```
/// error: Not found
/// context:
/// 0: fetching user
/// 1: loading dashboard
/// sources:
/// ╰─▶ HTTP 404
/// ```
pub fn pretty_print(err: KindError(kind)) -> String {
let root_doc =
doc.from_string("error: " <> err.kind.format(FormatArgs(detail: High)))
let parts = [root_doc]
let parts = case err.context {
[] -> parts
context -> {
let lines =
list.index_map(context, fn(c, i) {
doc.from_string(" " <> int.to_string(i) <> ": " <> c())
})
|> doc.join(with: doc.line)
let context_doc =
[doc.from_string("context:"), lines]
|> doc.join(with: doc.line)
|> doc.force_break
list.append(parts, [context_doc])
}
}
let parts = case err.sources {
[] -> parts
sources -> {
let source_docs = case sources {
[] -> []
[single] -> [source_to_doc(single, True)]
[first, ..rest] -> {
let rest_docs = list.map(rest, fn(s) { source_to_doc(s, True) })
[source_to_doc(first, False), ..rest_docs]
}
}
let sources_doc =
[doc.from_string("sources:"), ..source_docs]
|> doc.join(with: doc.line)
|> doc.force_break
list.append(parts, [sources_doc])
}
}
parts
|> doc.join(with: doc.line)
|> doc.force_break
|> doc.to_string(80)
}
fn source_to_doc(source: Format, is_last: Bool) -> doc.Document {
let connector = case is_last {
True -> "╰─▶ "
False -> "├─▶ "
}
let cont_prefix = case is_last {
True -> " "
False -> "│ "
}
case string.split(source(FormatArgs(detail: High)), "\n") {
[] -> doc.from_string(connector)
[first, ..rest] -> {
let docs = [
doc.from_string(connector <> first),
..list.map(rest, fn(line) { doc.from_string(cont_prefix <> line) })
]
docs |> doc.join(with: doc.line) |> doc.force_break
}
}
}