diff --git a/README.md b/README.md
index b0621d7..83823ec 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,9 @@ of redaction and metadata.
a heuristic classifier and conservative mappings into the canonical taxonomy
for teams that want a consistent baseline out of the box.
- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions
- with your domain errors without allocating or formatting on the happy path.
+ with your domain errors without allocating or formatting on the happy path,
+ while `app_error!` builds ad-hoc `AppError` values with `format!`-style
+ messages in expression position.
@@ -333,6 +335,23 @@ assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));
~~~
+`app_error!` is the `anyhow::anyhow!` counterpart: an expression macro that
+builds an `AppError` from a kind and an optional `format!`-style message with
+implicit capture. The kind-only form performs no allocation; the message form
+allocates exactly once.
+
+~~~rust
+use masterror::{AppErrorKind, AppResult, app_error};
+
+fn find(id: u64) -> AppResult
{
+ None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))
+}
+
+let bare = app_error!(AppErrorKind::Timeout);
+assert!(bare.message.is_none());
+assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound));
+~~~
+
diff --git a/README.template.md b/README.template.md
index b1ec72b..448747f 100644
--- a/README.template.md
+++ b/README.template.md
@@ -95,7 +95,9 @@ of redaction and metadata.
a heuristic classifier and conservative mappings into the canonical taxonomy
for teams that want a consistent baseline out of the box.
- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions
- with your domain errors without allocating or formatting on the happy path.
+ with your domain errors without allocating or formatting on the happy path,
+ while `app_error!` builds ad-hoc `AppError` values with `format!`-style
+ messages in expression position.
@@ -328,6 +330,23 @@ assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));
~~~
+`app_error!` is the `anyhow::anyhow!` counterpart: an expression macro that
+builds an `AppError` from a kind and an optional `format!`-style message with
+implicit capture. The kind-only form performs no allocation; the message form
+allocates exactly once.
+
+~~~rust
+use masterror::{AppErrorKind, AppResult, app_error};
+
+fn find(id: u64) -> AppResult
{
+ None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))
+}
+
+let bare = app_error!(AppErrorKind::Timeout);
+assert!(bare.message.is_none());
+assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound));
+~~~
+
diff --git a/src/lib.rs b/src/lib.rs
index ec0a9f2..c7b7f31 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -364,6 +364,12 @@ mod macros;
#[cfg(masterror_has_error_generic_member_access)]
#[doc(hidden)]
pub mod provide;
+
+/// Implementation detail of exported macros. Not part of the public API.
+#[doc(hidden)]
+pub mod __private {
+ pub use alloc::format;
+}
mod response;
mod result_ext;
diff --git a/src/macros.rs b/src/macros.rs
index 46282c1..eb039ae 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -2,14 +2,20 @@
//
// SPDX-License-Identifier: MIT
-//! Control-flow macros for early returns with typed errors.
+//! Control-flow and construction macros for typed errors.
//!
-//! These macros complement the typed [`AppError`](crate::AppError) APIs by
-//! providing a lightweight, allocation-free way to short-circuit functions when
-//! invariants are violated. Unlike the dynamic formatting helpers offered by
-//! `anyhow` or `eyre`, the macros operate on pre-constructed error values so
-//! the compiler keeps strong typing guarantees and no formatting work happens
-//! on the success path.
+//! The control-flow macros ([`ensure!`](crate::ensure) and
+//! [`fail!`](crate::fail)) complement the typed [`AppError`](crate::AppError)
+//! APIs by providing a lightweight, allocation-free way to short-circuit
+//! functions when invariants are violated. Unlike the dynamic formatting
+//! helpers offered by `anyhow` or `eyre`, they operate on pre-constructed
+//! error values so the compiler keeps strong typing guarantees and no
+//! formatting work happens on the success path.
+//!
+//! The expression macro [`app_error!`](crate::app_error) covers the ad-hoc
+//! construction side: it builds an [`AppError`](crate::AppError) from a kind
+//! and an optional format-style message, mirroring `anyhow::anyhow!` while
+//! staying inside the typed taxonomy.
//!
//! ```rust
//! use masterror::{AppError, AppErrorKind, AppResult};
@@ -102,3 +108,74 @@ macro_rules! fail {
return Err($err);
};
}
+
+/// Construct an [`AppError`](crate::AppError) expression from a kind and an
+/// optional format-style message.
+///
+/// This macro is the typed counterpart of `anyhow::anyhow!`. It evaluates to
+/// an [`AppError`](crate::AppError) value, so it can be used anywhere an
+/// expression is expected: `ok_or_else`, `map_err`, `return Err(...)` or as
+/// the argument to [`fail!`](crate::fail).
+///
+/// Two forms are supported:
+///
+/// - `app_error!(kind)` expands to [`AppError::bare`](crate::AppError::bare)
+/// and performs no allocation.
+/// - `app_error!(kind, "format {args}")` expands to
+/// [`AppError::with`](crate::AppError::with) with a message built by
+/// [`format!`](alloc::format), including implicit named-argument capture.
+/// Exactly one allocation happens, driven by `format_args!`.
+///
+/// # Examples
+///
+/// Kind-only, allocation-free:
+///
+/// ```rust
+/// use masterror::{AppErrorKind, app_error};
+///
+/// let err = app_error!(AppErrorKind::Timeout);
+/// assert!(matches!(err.kind, AppErrorKind::Timeout));
+/// assert!(err.message.is_none());
+/// ```
+///
+/// Formatted message with implicit capture:
+///
+/// ```rust
+/// use masterror::{AppErrorKind, app_error};
+///
+/// let value = 42;
+/// let err = app_error!(AppErrorKind::Validation, "bad value: {value}");
+/// assert!(matches!(err.kind, AppErrorKind::Validation));
+/// assert_eq!(err.message.as_deref(), Some("bad value: 42"));
+/// ```
+///
+/// Expression position and composition with [`fail!`](crate::fail):
+///
+/// ```rust
+/// use masterror::{AppErrorKind, AppResult, app_error};
+///
+/// fn find(id: u64) -> AppResult {
+/// let found = None::;
+/// let value = found.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))?;
+/// Ok(value)
+/// }
+///
+/// fn reject() -> AppResult<()> {
+/// masterror::fail!(app_error!(AppErrorKind::Unauthorized, "token expired"));
+/// }
+///
+/// assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound));
+/// assert!(matches!(
+/// reject().unwrap_err().kind,
+/// AppErrorKind::Unauthorized
+/// ));
+/// ```
+#[macro_export]
+macro_rules! app_error {
+ ($kind:expr $(,)?) => {
+ $crate::AppError::bare($kind)
+ };
+ ($kind:expr, $($fmt:tt)+) => {
+ $crate::AppError::with($kind, $crate::__private::format!($($fmt)+))
+ };
+}
diff --git a/tests/app_error_macro.rs b/tests/app_error_macro.rs
new file mode 100644
index 0000000..6d6cdfe
--- /dev/null
+++ b/tests/app_error_macro.rs
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2025 RAprogramm
+//
+// SPDX-License-Identifier: MIT
+
+use masterror::{AppCode, AppErrorKind, AppResult, app_error};
+
+#[test]
+fn kind_only_form_builds_bare_error() {
+ let err = app_error!(AppErrorKind::Timeout);
+ assert!(matches!(err.kind, AppErrorKind::Timeout));
+ assert!(err.message.is_none());
+}
+
+#[test]
+fn kind_only_form_accepts_trailing_comma() {
+ let err = app_error!(AppErrorKind::NotFound,);
+ assert!(matches!(err.kind, AppErrorKind::NotFound));
+ assert!(err.message.is_none());
+}
+
+#[test]
+fn formatted_form_captures_variables() {
+ let user_id = 42;
+ let err = app_error!(AppErrorKind::Validation, "invalid user {user_id}");
+ assert!(matches!(err.kind, AppErrorKind::Validation));
+ assert_eq!(err.message.as_deref(), Some("invalid user 42"));
+}
+
+#[test]
+fn formatted_message_equals_format_output() {
+ let name = "payments";
+ let attempt = 3;
+ let err = app_error!(
+ AppErrorKind::ExternalApi,
+ "service {name} failed after {attempt} attempts"
+ );
+ assert_eq!(
+ err.message.as_deref(),
+ Some(format!("service {name} failed after {attempt} attempts").as_str())
+ );
+}
+
+#[test]
+fn formatted_form_supports_positional_arguments() {
+ let err = app_error!(AppErrorKind::BadRequest, "expected {}, got {}", 1, 2);
+ assert_eq!(err.message.as_deref(), Some("expected 1, got 2"));
+}
+
+#[test]
+fn kind_and_code_are_derived_from_kind() {
+ let err = app_error!(AppErrorKind::Unauthorized, "token expired");
+ assert!(matches!(err.kind, AppErrorKind::Unauthorized));
+ assert_eq!(err.code, AppCode::Unauthorized);
+}
+
+#[test]
+fn works_in_ok_or_else_expression_position() {
+ fn find(id: u64) -> AppResult {
+ None::.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))
+ }
+ let err = find(7).unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::NotFound));
+ assert_eq!(err.message.as_deref(), Some("no entity 7"));
+}
+
+#[test]
+fn works_in_map_err_expression_position() {
+ fn parse(input: &str) -> AppResult {
+ input
+ .parse::()
+ .map_err(|parse_err| app_error!(AppErrorKind::BadRequest, "not a number: {parse_err}"))
+ }
+ let err = parse("abc").unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::BadRequest));
+ assert!(
+ err.message
+ .as_deref()
+ .is_some_and(|msg| msg.starts_with("not a number:"))
+ );
+}
+
+#[test]
+fn composes_with_fail_macro() {
+ fn reject() -> AppResult<()> {
+ masterror::fail!(app_error!(AppErrorKind::Forbidden, "admin only"));
+ }
+ let err = reject().unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::Forbidden));
+ assert_eq!(err.message.as_deref(), Some("admin only"));
+}
+
+#[test]
+fn composes_with_ensure_macro() {
+ fn guard(flag: bool) -> AppResult<()> {
+ masterror::ensure!(flag, app_error!(AppErrorKind::BadRequest, "flag required"));
+ Ok(())
+ }
+ assert!(guard(true).is_ok());
+ let err = guard(false).unwrap_err();
+ assert!(matches!(err.kind, AppErrorKind::BadRequest));
+}