Errors
E# expresses failure two complementary ways. Result<T, E> and the ? operator are the primary,
value-based surface — expected, recoverable failure modeled as a value you pass, inspect, and propagate,
and how E# code idiomatically signals fallibility to itself. Exceptions are the CLR’s mechanism for
exceptional conditions; try / catch / throw handle them, and they stay a real concern — the BCL
throws, and even a Result’s escape hatches (Unwrap, .Value on the wrong variant) throw. The two are
not separate worlds but two ends of one error model, split by whether a failure is expected (return a
Result) or exceptional (let it throw), and they convert at the boundary: catch a thrown exception
where it arises, translate it into a Result, and continue in value-based flow.
In this specification
Section titled “In this specification”This page remains the complete error-model reference. The focused subpages give the full normative shape of the two value-flow mechanisms:
- Result values and operations — representation, construction, accessors, match case views, combinators, and CLR interop.
- Propagation with
?— postfix grammar, short-circuit behavior, expression positions, async completion, and the boundaries with?.and??.
Result<T, E>
Section titled “Result<T, E>”Result<TValue, TError> is a builtin. The compiler lowers every surface below against one concrete type
— the value struct Esharp.Stdlib.Result`2, authored in E# in the standard library and resolved by
metadata name — so the language surface and direct C# interop are backed by the same type, with no
generated facade.
Its representation is a value struct with exactly three fields:
pub struct Result<TValue, TError> { pub IsOk: bool pub Value: TValue pub Error: TError}A Result carries its discriminant and both payload slots inline, is copied on assignment, has no
identity, and costs no allocation on the hot path — every ok, error, and ? works against the struct
directly. The fields are public because the compiler’s accessor intrinsics read them across the assembly
boundary; the variant invariant — Value is meaningful iff IsOk, Error is meaningful iff !IsOk
— is upheld by construction, because ok / error / the combinators are the only ways a Result is
built.
A TError is user-chosen and commonly a union enumerating
exactly what can go wrong.
Construction
Section titled “Construction”The constructors ok(value) and error(value) are expressions and the idiomatic way to build a
Result. They are compiler intrinsics: each lowers to an inline initobj + field stores on the pinned
struct (zeroing the unused variant slot), so construction never calls through a factory and never
allocates. Inside error(...) the .case dot-shorthand resolves against the known error type.
let a = ok(42) // ok sidelet e = error(.notFound) // error side; .notFound resolves against the error unionA static factory class mirrors the constructors for explicit and interop use. The value type
Result`2 and the static class Result (arity 0) coexist under the one name by
arity keying; the factory is callable identically from E# and C#:
let b = Result.Ok<int, string>(42) // explicit type argumentslet c = Result.Error<int, string>("nope")The error side therefore has three distinct, deliberate spellings, each in its own position:
| Spelling | Position | Builds / names |
|---|---|---|
error(e) | expression — construction | the intrinsic constructor |
Result.Error<T, E>(e) | expression — explicit factory | the static factory, for interop and explicit type arguments |
.err(e) | a match arm | the error case view |
(The success side parallels it: ok(v), Result.Ok<T, E>(v), and the .ok(v) match arm.)
Accessors
Section titled “Accessors”Four accessor intrinsics read a Result directly:
| Accessor | Type | Behavior |
|---|---|---|
.IsOk | bool | the discriminant field |
.IsError | bool | the negation of IsOk — synthesized, not a stored field |
.Value | TValue | the success payload; throws InvalidOperationException on an error |
.Error | TError | the error payload; throws InvalidOperationException on a success |
The accessor intrinsic guards each field load, so reading the wrong variant’s payload is the documented
failure — the variant invariant is enforced at the read, not merely trusted. .IsError exists for
readable branch tests even though the underlying struct stores only IsOk:
func describe(r: Result<int, string>) -> string { if r.IsError { return "failed: {r.Error}" } return "ok: {r.Value}"}Combinators
Section titled “Combinators”Ten combinators are methods — each has a Result receiver, so it reads as r.Method(...)
(Functions → methods).
They compose in fully-typed contexts: type arguments flow through the receiver and through lambda
arguments (Generics → type-argument inference), so the chain
below stays typed end to end with every lambda parameter and result inferred.
| Method | Signature (receiver r: Result<T, E>) | Result |
|---|---|---|
Map | (f: Func<T, U>) -> Result<U, E> | transform the ok value; error passes through |
MapErr | (f: Func<E, F>) -> Result<T, F> | transform the error; ok passes through |
Bind | (f: Func<T, Result<U, E>>) -> Result<U, E> | chain a fallible step onto the ok value (monadic bind) |
Match | (ok: Func<T, O>, err: Func<E, O>) -> O | fold both variants into one type |
Inspect | (onOk: Action<T>) -> Result<T, E> | side-effect on ok; returns r unchanged |
InspectErr | (onErr: Action<E>) -> Result<T, E> | side-effect on error; returns r unchanged |
UnwrapOr | (fallback: T) -> T | the ok value, or a constant fallback |
UnwrapOrElse | (fallback: Func<E, T>) -> T | the ok value, or a fallback computed from the error |
Unwrap | () -> T | the ok value; throws on error |
UnwrapErr | () -> E | the error value; throws on success |
func go() -> int = parse(10).Map((x) => x + 1).Bind((x) => dbl(x)).Match((v) => v - 1, (e) => -1).Match (the combinator) and match (the statement, below) are two routes to the
same fold; .Match is the expression-position form that returns directly into a chain. Unwrap /
UnwrapErr are the throwing escapes — the conscious hot-path exception, parallel to .Value / .Error.
Each combinator is a value-receiver method on the Result struct (the receiver block (r: Result<…>)),
so it reads as r.Method(...) — a snapshot copy of the value, never mutating the original. The receiver
threads through the generic parameters and the method adds its own, and the success/error variant is read
through the .IsOk discriminant:
pub func (r: Result<TValue, TError>) Map<TValue, TError, TNew>(f: Func<TValue, TNew>) -> Result<TNew, TError> { if r.IsOk { return ok(f(r.Value)) } return error(r.Error)}
pub func (r: Result<TValue, TError>) UnwrapOr<TValue, TError>(fallback: TValue) -> TValue { if r.IsOk { return r.Value } return fallback}
pub func (r: Result<TValue, TError>) Unwrap<TValue, TError>() -> TValue { if not r.IsOk { throw InvalidOperationException("called Unwrap on an error Result") } return r.Value}A static Result factory class pairs with the Result<TValue, TError> value type by
arity keying — Result`2 (the struct) and Result (arity-0 static
class) coexist under the one name — and mirrors the ok / error intrinsics for explicit and C# interop
use:
pub static Result { pub func Ok<TValue, TError>(value: TValue) -> Result<TValue, TError> = ok(value) pub func Error<TValue, TError>(err: TError) -> Result<TValue, TError> = error(err)}match over a Result
Section titled “match over a Result”A Result is destructured with match over two case views, .ok and .err,
binding the respective payload:
func go() -> int { let a = parse(10)? let b = step(a)? match step(b) { .ok(v) { return v + a + b } .err(e) { return -1 } } return -2}The two cases are exhaustive, so a match covering both .ok and .err needs no default and satisfies
definite return. match also works as an expression
(every arm one common type) and composes under await, so an awaited Result is matched in place.
The ? operator
Section titled “The ? operator”expr? evaluates expr to a Result and forks on the variant:
- ok → the operator’s value is the unwrapped
okpayload, left in place for the surrounding expression to use; - error → the enclosing function returns immediately. The compiler builds a fresh
Resultof the enclosing function’s return type and moves the inner result’s error value into it — the error type carries through unchanged, the success type re-targets to whatever the caller returns.
? is a real expression operator, not a statement form: it composes anywhere an expression appears, and
each of these positions is distinct:
let user = findUser(id)? // a let initializerreturn ok(parse(raw)? + 1) // inside a return, feeding ok(...)write(parse(raw)?) // a call argumentvalidate(input)? // a bare statement — continue on ok, propagate on errorlet n = step(parse(x)?)? // nested — the inner ? feeds a call whose result is ?'d againIn a chain of statements each carrying a ?, evaluation stops at the first error and that error is
what propagates; later statements never run.
One ? per evaluation step
Section titled “One ? per evaluation step”Each ? is its own ok/error fork, and a single evaluation step resolves at most one. A compound
expression that would propagate two results is written as sequential lets — one ? per step, which is
also where the names that document the flow live:
let a = f()?let b = g()?return ok(a + b)This is the canonical shape for fanning several fallible calls into one expression, and it reads more clearly than nesting the propagations.
Two spellings to keep distinct
Section titled “Two spellings to keep distinct”expr?.memberis the null-conditional operator. The lexer fuses?.into one token (Lexical → operators), soresult?.fieldis a null-conditional access, never “unwrapresult, then.field”. To unwrap-then-access, parenthesise —(result?).field— or bind the unwrapped value with alet. A fallible fluent chain (machine.insert(25)?.insert(10)?) is therefore written as sequentiallets, one unwrap per step.- Ternary versus try-unwrap. A
?is read as the ternary operator when the following token can begin an expression, and as postfix try-unwrap otherwise (Expressions).
Error-type sharing
Section titled “Error-type sharing”Because the propagated error value is reused as-is, the enclosing function’s error type is the same as
the ?’d result’s error type. This is the practical reason a module standardizes on one error union:
every ? in a call graph carries the same E, and only the success type changes from function to
function.
? under async
Section titled “? under async”? composes with await. When a ? propagates inside an asynchronous function, the early return is
routed through the async builder’s completion path (SetResult) rather than a raw ret, so an error
short-circuit out of a state machine is well-formed. Both the ok and error paths of ? are exercised
under async.
Optionals versus Result
Section titled “Optionals versus Result”| Tool | Meaning |
|---|---|
T? | optional presence — “there might not be a value” |
Result<T, E> | a fallible operation — “this can fail, and here is why” |
Use T? for “maybe there is a user”; use Result for “the lookup can fail with notFound / timeout /
connectionFailed.” See Type system → nullability.
let-else
Section titled “let-else”A let … else guard binds a value or diverges; the else block shall leave the scope. The full grammar
and rules are in Statements → let-else.
let user = db.find(id) else { return error(.notFound) }// user is non-nil from here onExceptions — try / catch / throw
Section titled “Exceptions — try / catch / throw”Exceptions are the CLR’s mechanism for exceptional conditions, and try / catch / throw are how E#
handles them — the complement to the value-based Result flow above, not a thing apart from it. Because
the BCL throws, the most common use is to catch at that seam and translate into a Result, returning
to value-based flow:
func parsePort(s: string) -> Result<int, string> { try { return ok(int.Parse(s)) } catch (e: FormatException) { return error("bad port: {e.Message}") }}There is no finally — use defer inside the try. throw e raises an
exception; throw with no operand rethrows and is valid only inside a catch. The three catch shapes and
the grammar are in Statements → try / catch / throw. The guideline
is fit, not prohibition: model expected failure as a Result, let exceptional conditions throw,
and convert between the two where they meet.