Error propagation and carrier types
Result and Optional let a function report failure or a missing value without
throwing an exception. Raven provides short forms for returning those cases
early so the main path stays easy to read.
Combining error types with an ad-hoc union
Use an ad-hoc union when a function should preserve several existing error types
without inventing a common error type or writing implicit converters solely for
propagation. For example, given ReadFile returning Result<string, IOError> and
ParseConfig returning Result<Config, ParseError>:
func LoadConfig(path: string) -> Result<Config, IOError | ParseError> {
let text = ReadFile(path)?
let config = ParseConfig(text)?
return Ok(config)
}
Each ? extracts the success value or injects the error into the enclosing error
union and returns immediately. Both alternatives are visible in the signature and
can be discovered and distinguished with typed patterns after extracting
Failure(let error). Neither error type needs a shared interface or base class, and
no application-defined conversion is needed for either listed alternative. An
unrelated error is rejected. ? yields the success payload; Ok(config) explicitly
constructs the final successful Result.
This is an alternative to propagating one particular error type or mapping into a nominal error union. Prefer a dedicated error type or nominal union for many public APIs: it gives the contract a domain name, meaningful cases and a boundary that can remain independent of implementation dependencies. Use an implicit converter when that domain adaptation is intentional, rather than just to aggregate alternatives.
Review existing APIs as well as new ones: an error wrapper that only forwards one of several unchanged errors may be clearer as an ad-hoc union. Weigh the simpler contract and discoverable alternatives against source compatibility, public dependency exposure and any meaning carried by the old case names. A preference for nominal public contracts is not a reason to retain wrappers that add no useful domain meaning.
Raven's ordinary .NET implementation backs A | B with the Raven.Core
System.Union<A, B> carrier. Two through five alternatives are supported by that
bridge. The neoCLR target needs its matching development library and compiler;
its temporary nominal carriers are not native structural union values. Structural
identity, normalization and future native representation are separate work.
Ad-hoc error unions can also appear directly in generic constructor and explicit generic method type arguments, including nested Result types. A type alias is not required to construct a promise or other generic container for such a result.
Try expressions
try captures exceptions as values. try expression? combines capture with carrier
propagation.
Together, these forms provide an interop boundary from exception-throwing APIs
to Raven's value-based error flow. try is appropriate when code needs to
inspect or transform the captured failure locally. try expression? is appropriate when
the enclosing carrier-returning operation cannot continue and should propagate
that failure immediately.
| Form | Result type | Success case | Failure case |
|---|---|---|---|
try expr |
Result<T, Exception> |
Ok(value) or Ok(()) |
Failure(exception) |
try expr? |
T inside an enclosing carrier-returning context |
yields the success payload | propagates the captured error through the enclosing Result/Optional |
try expr evaluates expr exactly once and converts the outcome to
Result<T, Exception>, where T is the operand type. If the operand has type
unit, the success case is Ok(()).
try expr? composes exception capture with ordinary propagation. It is valid
only when the enclosing
function or lambda returns a compatible Result<_, _> or Optional<_>.
Propagation wraps the complete try expression: try Foo()? means
(try Foo())?, not try (Foo()?). Parentheses can still explicitly place
propagation inside the operand. try Foo() match { ... } matches the captured
result; try Foo()? match { ... } matches its success payload after propagation.
Examples
import System.*
func describeNumber(text: string) -> string {
return try Convert.ToInt32(text) match {
Ok(let value) => "Parsed: $value"
Failure(FormatException ex) => "Invalid format: ${ex.Message}"
Failure(_) => "Unexpected failure"
}
}
func parseRequiredInt(text: string) -> Result<int, Exception> {
let value = try Convert.ToInt32(text)?
return Ok(value)
}
func saveText(path: string, text: string) -> string {
return try System.IO.File.WriteAllText(path, text) match {
Ok => "Saved"
Failure(UnauthorizedAccessException ex) => "Access denied: ${ex.Message}"
Failure(IOException ex) => "I/O error: ${ex.Message}"
Failure(_) => "Unexpected failure"
}
}
Convert.ToInt32 is used here because it remains a throwing .NET API. Raven's
standard framework projection for int.Parse(string) already returns
Result<int, FormatException | OverflowException>;
try is still the general mechanism for capturing exceptions from APIs without
such a projection. Setting RavenFrameworkProjections to None restores the
ordinary throwing CLR int.Parse member.
Detailed rules
- The operand may be any expression that is valid in the current context.
try exprdoes not acceptcatchorfinallyclauses; use statement-formtryfor structured exception handling.- Nested
tryexpressions are invalid and produceRAV1906. try expr?may be followed bymatch, like other propagated expressions.- The former
try? exprsyntax is rejected withRAV1925. - Each postfix
?propagates one carrier layer. Ifexpralready returns aResult<T, E>,try expr?preserves that inner result. To propagate both layers, write((try expr)?)?or bind the intermediate result and propagate it. awaitmay appear insidetry exprwhen the enclosing context is async.- In pattern position,
Okis shorthand forOk(())when the success payload isunit;.Okremains available as target-typed shorthand.
Await and propagation
await Foo()? means (await Foo())?: first await the task, then unwrap or
propagate the carrier it returns. It does not propagate from the task itself.
Write await (Foo()?) explicitly to propagate a carrier containing a task
before awaiting that task.
await and try remain separate expressions. await does not capture task
exceptions as a Result; try await Foo()? means (try await Foo())?, capturing
exceptions from the awaited operation and then propagating the captured result.
If the awaited value is itself a Result, it remains a separate carrier layer.
This precedence rule applies to standalone postfix ?. Conditional-access
trailers (?., ?(, ?[) retain their existing operand grouping. Calls, member
access, and indexing also remain part of the operand. Propagation inside an
argument or explicit parentheses stays inside that nested expression.
Result and Optional carrier operators
Result<T, E> and Optional<T> share the same carrier terminology throughout the
spec: ? unwraps or propagates, and ?. conditionally maps over the success
case.
| Form | Receiver | Result | Empty or error case |
|---|---|---|---|
expr? |
Result<T, E> |
T |
propagates Failure(error) |
expr? |
Optional<T> |
T |
propagates None |
expr?.Member |
Result<T, E> |
Result<U, E> |
preserves Failure(error) |
expr?.Member |
Optional<T> |
Optional<U> |
preserves None |
Propagation (?)
The postfix ? operator unwraps a carrier value and propagates the non-success
case to the nearest enclosing carrier-returning function or lambda.
Examples
func loadAndParse(path: string) -> Result<int, Exception> {
let text = ReadAllText(path)?
let value = ParseInt(text)?
return Ok(value)
}
func firstEven(values: int[]) -> Optional<int> {
let value = FindFirstEven(values)?
return Some(value)
}
Detailed rules
- For
Result<T, E>,Ok(value)yieldsvalueandFailure(error)immediately returnsFailure(error)from the enclosing context. - For
Optional<T>,Some(value)yieldsvalueandNoneimmediately returnsNonefrom the enclosing context. expr?is valid only when both the operand and the enclosing function or lambda return type implement compatibleIPropagatablecontracts.- The operand is evaluated exactly once; the compiler may introduce temporaries to preserve that rule.
expr?performs propagation only when?is not followed by a trailer. In postfix position,expr?.Member,expr?(...), andexpr?[...]are parsed as conditional-access forms instead. For a customIPropagatablecarrier,expr?.Memberpropagates the receiver and then accessesMemberon its output. (ResultandOptionalretain the lifted mapping behavior described below.)
If propagation relies on a user-defined implicit conversion on the error
channel, the compiler reports informational diagnostic RAV1506.
Custom carriers
Propagation is defined by the Raven.Core interface
IPropagatable<TSelf, TOutput, TResidual>, not by a required union shape or
type name:
public interface IPropagatable<TSelf, TOutput, TResidual> {
func TryGetOutput(out output: TOutput) -> bool
func TryGetResidual(out residual: TResidual) -> bool
static func FromResidual(residual: TResidual) -> TSelf
}
TryGetOutput returns true and assigns the value produced by expr? when
evaluation can continue. When it returns false, the compiler calls
TryGetResidual; a conforming implementation must then return true and
assign the residual. The residual is implicitly converted to the enclosing
carrier's residual type and passed to that carrier's FromResidual method for
the early return. The operand is evaluated exactly once.
TSelf makes the static reconstruction member usable without associated types,
which Raven does not currently expose in interface declarations. It must be the
concrete implementing carrier. Different carrier types may interoperate when
their residual types are implicitly convertible. Result<T, E> implements the
contract with output T and residual E; Optional<T> implements it with output
T and the unit residual ().
Conditional member access (?.)
Carrier conditional access maps a member access over the success case of a carrier without unwrapping the carrier itself.
Examples
record class User(Name: string, Item: Optional<Item>)
record class Item(Name: string)
union LookupError {
case MissingUser
case MissingItem
}
func getUser() -> Result<User, LookupError> {
return Failure(.MissingUser)
}
func userNameLength() -> Result<int, LookupError> {
let length = getUser()?.Name?.Length?
return Ok(length)
}
func selectedItemName() -> Result<string, LookupError> {
let maybeItem = getUser()?.Item?
match maybeItem {
Some(let item) => Ok(item.Name)
None => Failure(.MissingItem)
}
}
Detailed rules
- For
Result<T, E>,expr?.MemberevaluatesMemberonly whenexprisOk(payload)and returnsResult<U, E>. - For
Optional<T>,expr?.MemberevaluatesMemberonly whenexprisSome(payload)and returnsOptional<U>. - The member-access form is the only lifted carrier conditional-access form.
expr?[index]andexpr?(args)are not lifted forResultorOptional.- If indexing or invocation is required, unwrap first with
?, pattern matching, or an explicit helper API.
For nullable/reference receivers, Raven also supports null-conditional member assignment in statement position:
person?.Name = "Ada"
counter?.Value += 1
These forms evaluate the receiver once, execute the assignment only when the receiver is non-null, and otherwise do nothing.
End-to-end example
The following example shows try, propagation, and carrier conditional access
working together:
record class User(Name: string)
union LookupError {
case Io(message: string)
case InvalidUser
}
func readNameLength(path: string) -> Result<int, LookupError> {
let text = try System.IO.File.ReadAllText(path) match {
Ok(let content) => Ok(content)
Failure(let ex) => Failure(.Io(ex.Message))
}?
let length = parseUser(text)?.Name?.Length?
return Ok(length)
}
func parseUser(text: string) -> Result<User, LookupError> {
if text == "" {
return Failure(.InvalidUser)
}
return Ok(User(text))
}
Standard carrier helper APIs (Raven.Core)
Raven ships Optional<T>, Result<T, E>, and related extension helpers in
Raven.Core. These are library APIs (not syntax), but they are part of the
standard language experience and are expected by diagnostics, samples, and
tooling.
Optional<T> and Result<T, E> are defined in Raven.Core as plain union
carriers, so they use Raven's default struct-union representation and follow the
conventional .NET union contract.
Raven.Core also provides System.Text.Json converters for Optional<T>,
Result<T, E>, and ad-hoc System.Union<...> values. These converters prefer plain JSON
when the JSON shape can be recovered from the declared target type. Parenthesized
unions use value-union conversion because their members can be arbitrary types,
including primitives; case-declaration unions may opt into a tagged case format.
See
Raven Core JSON serialization.
Optional<T> helpers
- State checks:
HasSome,HasNone - Mapping/composition:
Map,Then,Where,Filter,OrElse - Result bridge:
ThenResult,MapResult,OkOr(error),OkOr(errorFactory) - Pattern/value helpers:
Match,Tap,TapNone - Unwrap helpers:
UnwrapOrElse,UnwrapOrDefault,UnwrapOrThrow,UnwrapOr(defaultValue) - Enumeration helpers:
ToEnumerable,GetEnumerator - Nested carrier helper:
FlattenforOptional<Optional<T>> - Nullable conversions:
Optional<T : class> <-> T?Optional<T : struct> <-> T?
Result<T, E> helpers
- State checks:
HasOk,HasError - Channel projection:
IsOk,IsError - Mapping/composition:
Map,Then,MapError,OrElse - Pattern/value helpers:
Match,Tap,TapError - Unwrap helpers:
UnwrapOrElse,UnwrapOrDefault,UnwrapOrThrow,UnwrapOr(defaultValue),UnwrapError - Enumeration helpers:
ToEnumerable,GetEnumerator
Carrier LINQ extensions on IEnumerable<T> (System.Linq)
- Optional-returning:
FirstOrNone(),FirstOrNone(predicate)LastOrNone(),LastOrNone(predicate)SingleOrNone(),SingleOrNone(predicate)ElementAtOrNone(index)
- Result-returning with custom errors:
FirstOrError(errorFactory),FirstOrError(predicate, errorFactory)LastOrError(errorFactory),LastOrError(predicate, errorFactory)SingleOrError(errorFactory),SingleOrError(predicate, errorFactory)SingleOrError(errorIfNone, errorIfMany)SingleOrError(predicate, errorIfNone, errorIfMany)ElementAtOrError(index, errorFactory)
- Result-returning with captured exceptions:
ToArrayOrException() -> Result<T[], Exception>ToListOrException() -> Result<List<T>, Exception>ToHashSetOrException() -> Result<HashSet<T>, Exception>ToDictionaryOrException(keySelector) -> Result<Dictionary<TKey, T>, Exception>ToDictionaryOrException(keySelector, elementSelector) -> Result<Dictionary<TKey, TValue>, Exception>
- Result-returning with mapped errors:
ToArrayOrError(errorFactory: Exception -> E) -> Result<T[], E>ToListOrError(errorFactory: Exception -> E) -> Result<List<T>, E>ToHashSetOrError(errorFactory: Exception -> E) -> Result<HashSet<T>, E>ToDictionaryOrError(keySelector, errorFactory) -> Result<Dictionary<TKey, T>, E>ToDictionaryOrError(keySelector, elementSelector, errorFactory) -> Result<Dictionary<TKey, TValue>, E>
Experimental runtime target selection
An explicit runtime propagation contract selects the target's carrier interface and uses result-only propagation without implicit exception capture. This opt-in compiler setting leaves default .NET behavior unchanged; it does not introduce syntax or make exception-handling constructs portable.