ADR 0033: Propagating C++ results¶
Status: Accepted
Date: 2026-09-28
Context¶
ADR 0032 made the C++17 wrapper Result-first so that hosts built without exceptions can use it. The first host to adopt it showed the cost. Most of the adapter’s functions call several PostProject operations and pass any failure to their own caller. Without exceptions, every such call is followed by the same three lines:
if (!result.ok()) {
return std::move(result).takeError();
}
The adapter has 22 of these. They account for about half of its growth since
it stopped using exceptions, and they make the code harder to read than the
version that relied on one try block. Every C++ host built without
exceptions writes the same pattern at every call. That includes KDE
applications, game engines, and plugins behind C plugin interfaces such as
OpenFX.
Hosts that allow exceptions are not affected: value() throws, and one try
block covers a whole function. Python hosts, such as Blender add-ons, use the
Python binding, which raises exceptions.
The header already defines POSTPROJECT_TRY and POSTPROJECT_TRY_ASSIGN to
propagate errors in one line, but it uses them only internally and undefines
them at its end.
Result also uses its own names for accessors that C++23 standardized in
std::expected, such as ok() for has_value() and valueOr() for
value_or(). Integrators who know std::expected or std::optional must learn
a second set of names for the same operations. Pre-1.0 hosts cannot move to
C++23 without every PostProject call site changing.
Decision¶
Propagation macros are public. The header keeps the following macros defined and documents them as supported API:
POSTPROJECT_TRY(expression)evaluates aResultand, if it holds an error, returns thatErrorfrom the enclosing function.POSTPROJECT_TRY_ASSIGN(declaration, expression)does the same, and otherwise declares a variable holding the value. For example:POSTPROJECT_TRY_ASSIGN(auto production, Production::open(path));
The enclosing function must return a type that is implicitly constructible from
postproject::Error. Any postproject::Result<U> qualifies. A host can
propagate into its own error type by giving that type such a constructor.
POSTPROJECT_TRY_ASSIGN expands to several statements, so it may only be used
as a statement directly inside a block, and only once per source line. Its
declared variable belongs to the enclosing scope. Both macros work with and
without exceptions and use only standard C++17.
Result uses the std::expected vocabulary. The accessors and combinators
take the names and meanings of their C++23 std::expected counterparts, and
the wrapper’s own names are removed:
Before |
After |
|---|---|
|
|
|
|
|
|
The following combinators are added, with the same rules as in
std::expected:
and_then(f):ftakes the value and returns aResult<U>.transform(f):ftakes the value and returns aU.or_else(f):ftakes theErrorand returns aResult<T>.
Result<void> has every member that std::expected<void, E> has, except
value_or. Domain operations keep the wrapper’s camelCase names. Only the
vocabulary type follows the standard.
Two differences from std::expected remain and are documented:
The error type is always
postproject::Error. There is notransform_error.Resultis constructed implicitly from anError, withoutstd::unexpected. The propagation macros rely on this.
As before, value() on an error throws postproject::Exception when
exceptions are enabled and otherwise aborts.
Alternatives considered¶
Leave propagation to hosts. Each host would write its own copy of the macros, and every example in the documentation would keep the three-line checks. The header already contains correct, tested versions of the macros.
Combinators only, without macros. In C++17, chains of lambdas are harder to read than explicit checks once a function makes more than two calls or needs an intermediate value in a later call.
A transaction that records its first staging error and turns later calls into no-ops, as
MetadataInputandResolutionOptionsalready do. Adapters read from the production between staging calls, so reads would still need checks. It would also hide which call failed, and a failed staging call is a signal to roll back (see the lifecycle guide).Keep the camelCase names and add
andThen,transform, andorElse. This avoids renaming existing calls, but keeps a vocabulary only PostProject uses and rules out makingResultan alias ofstd::expectedlater.Make
Resultan alias ofstd::expected<T, Error>now. This needs C++23, and the wrapper targets C++17 (ADR 0032).
Standards impact¶
None. The change affects only the header-only C++ wrapper. The C ABI, the schema, and the domain model are unchanged.
Consequences¶
Hosts built without exceptions propagate a failure in one line.
POSTPROJECT_TRY and POSTPROJECT_TRY_ASSIGN become public names that must stay
stable within the integration-preview tier (ADR 0020). Existing C++ callers
rename ok(), valueOr(), and takeError(). The C++ examples, the
consumer tests, and the documentation use the macros wherever a function passes
failures on. A later move to C++23 can make Result an alias of
std::expected<T, Error>. That would need its own decision, because it changes
the exception thrown by value() and requires the macros to construct the
error through std::unexpected.