RZ.Foundation
8.4.0
dotnet add package RZ.Foundation --version 8.4.0
NuGet\Install-Package RZ.Foundation -Version 8.4.0
<PackageReference Include="RZ.Foundation" Version="8.4.0" />
<PackageVersion Include="RZ.Foundation" Version="8.4.0" />
<PackageReference Include="RZ.Foundation" />
paket add RZ.Foundation --version 8.4.0
#r "nuget: RZ.Foundation, 8.4.0"
#:package RZ.Foundation@8.4.0
#addin nuget:?package=RZ.Foundation&version=8.4.0
#tool nuget:?package=RZ.Foundation&version=8.4.0
RZ.Foundation
RZ.Foundation is a functional add-on to LanguageExt.
Its centrepiece is Outcome<T>: a result type that brings Go's "errors are values"
philosophy to C#. Instead of throwing exceptions and hoping someone, somewhere, remembers to
catch, a function simply returns either its value or a structured ErrorInfo — and
the compiler keeps you honest about which one you got.
// The signature tells the whole story: this can fail, and you must deal with it.
Outcome<User> FindUser(string id);
Why Outcome<T>?
In the exception model, the failure path is invisible. A method that returns User looks
total, but may blow up the call stack at runtime. You only discover the failure modes by
reading the implementation — or in production.
Outcome<T> makes failure a first-class, visible part of the return value:
- Explicit — failure is in the type signature, not hidden in a comment.
- Local — you handle (or deliberately forward) the error right where it happens.
- Cheap — no stack unwinding on the expected-error path; exceptions are reserved for the genuinely exceptional and for system boundaries.
Go vs. C#, side by side
// Go
v, err := DoThing()
if err != nil {
return err // forward the error upward
}
use(v)
// C# with RZ.Foundation
if (Fail(DoThing(), out var e, out var v))
return e.Trace(); // forward the error upward (and record this hop — see below)
Use(v);
Same shape, same intent. The C# side gets something extra for free: calling e.Trace()
stamps the current file/method/line onto the error as it travels up, so by the time it
surfaces you have a readable, application-level breadcrumb trail.
All examples below assume the project's global usings are in effect:
using static RZ.Foundation.AOT.Prelude;andusing static RZ.Foundation.StandardErrorCodes;.Outcome<T>lives inRZ.Foundation;ErrorInfoinRZ.Foundation.Types.
Outcome<T> in 60 seconds
An Outcome<T> is either a success carrying a T or a failure carrying an
ErrorInfo. You rarely construct it explicitly — values and errors implicitly convert:
Outcome<int> ok = 42; // success, by implicit conversion
Outcome<int> bad = ErrorInfo.New(INVALID_REQUEST, "Invalid parameter"); // failure, by a helper function with location attached
// Explicit factories when type inference needs a nudge:
var a = SuccessOutcome(42); // Outcome<int> success
var b = FailedOutcome<int>(ErrorInfo._NotFound());
var u = UnitOutcome; // Outcome<Unit>, the "succeeded, nothing to return" case
A function returning Outcome<T> therefore reads naturally — just return value; or
return error;:
Outcome<int> Divide(int a, int b)
=> b == 0 ? ErrorInfo.New(INVALID_REQUEST, "division by zero") : a / b;
ErrorInfo — the structured replacement for Exception
ErrorInfo is what Outcome<T> carries on failure. It is an immutable record designed to
describe an error well enough that you never need an exception type per failure mode.
| Field | Purpose |
|---|---|
Code |
Machine-readable category, e.g. "not-found" |
Message |
Human-readable description |
TraceId |
Distributed-trace id, auto-filled from Activity.Current |
DebugInfo |
Extra developer detail (omit in release responses) |
Data |
Serialized payload associated with the error |
InnerError |
The underlying cause, when this error wraps another |
SubErrors |
Sibling failures, when several things failed at once |
Locations |
The application-side call trail accumulated by Trace() |
Use the well-known codes from StandardErrorCodes (NOT_FOUND, INVALID_REQUEST, TIMEOUT,
CANCELLED, DUPLICATION, HTTP_ERROR, UNHANDLED, VALIDATION_FAILED, …) rather than
inventing strings. Every code also exists under a legacy PascalCase name (NotFound) — the same
string, kept for compatibility. Prefer the UPPER_SNAKE_CASE form in new code:
var notFound = ErrorInfo.New(NOT_FOUND, "user 42 does not exist");
var invalid = ErrorInfo.New(INVALID_REQUEST, "email is required");
// Inspect — Is() searches InnerError and SubErrors too, not just the top-level Code:
notFound.Is(NOT_FOUND); // true
notFound.IsNotFound(); // true — shorthand for the common case
// Re-tag a low-level error with higher-level context:
var wrapped = dbError.Wrap(SERVICE_ERROR, "failed to load user profile");
Synchronous flow
The everyday pattern is the guard: pull the value out, or short-circuit. Two mirror-image helpers cover it.
Success(...) is true on the happy path:
if (Success(FindUser(id), out var user, out var error))
Render(user);
else
Log(error);
Fail(...) is true on the failure path — perfect for early-return propagation:
Outcome<Invoice> BuildInvoice(string userId, string itemId) {
if (Fail(FindUser(userId), out var e, out var user)) return e.Trace();
if (Fail(FindItem(itemId), out e, out var item)) return e.Trace();
return new Invoice(user, item); // implicit conversion to Outcome<Invoice>
}
Always forward with
return e.Trace();, not barereturn e;.Trace()appends the current file/method/line to the error'sLocations. As the error bubbles throughBuildInvoice→ its caller → its caller, each hop leaves a marker, giving you an application-level trace that reads in your own terms — far more useful than a raw CLR stack trace, and it costs nothing on the success path.
There are convenient overloads. Take only what you need:
if (Fail(result, out var e)) return e.Trace(); // don't care about the value
if (Success(result, out var v)) Use(v); // don't care about the error
Tuple results deconstruct in one step:
if (Fail(LoadPair(), out var e, out var first, out var second)) return e.Trace();
// first and second are in scope here
To collapse an Outcome<T> into a plain value, use Match or IfFail:
var label = FindUser(id).Match(u => u.Name, _ => "(unknown)");
var count = CountRows().IfFail(0); // default on failure
var count2 = CountRows().IfFail(e => -1); // compute a default from the error
Asynchronous flow
Async code returns ValueTask<Outcome<T>>. The pattern is identical — await first, then
guard exactly as in the synchronous case:
async ValueTask<Outcome<Profile>> LoadProfile(string id) {
if (Fail(await FetchUser(id), out var e, out var user)) return e.Trace();
if (Fail(await FetchAvatar(user), out e, out var avatar)) return e.Trace();
return new Profile(user, avatar);
}
When the work might throw (an HTTP call, a DB driver), wrap it with TryCatch to turn an
exception into a failed Outcome instead of letting it escape (see
Bridging with exceptions for the full set):
ValueTask<Outcome<string>> GetBody(Uri url) =>
TryCatch(async () => await httpClient.GetStringAsync(url));
// a thrown HttpRequestException becomes a failed Outcome<string>
The NotFound convention
There is one error code the library treats specially: NotFound. It exists for a common
situation — a lookup that may legitimately find nothing, where "nothing" is an ordinary
outcome rather than a real failure.
You could model that with Outcome<Option<T>>: success-with-Some, success-with-None, or
failure. It is precise, but every caller now has to unwrap two layers. The lighter alternative
is to return a plain Outcome<T> and signal absence with a NotFound failure:
// Returns the user, or a NotFound failure if there is no such id.
Outcome<User> FindUser(string id)
=> TryLookup(id) is { } user ? user : ErrorInfo._NotFound(); // note, function form for attaching location
Callers that don't care about the distinction just forward like any other error. Callers that do care get purpose-built helpers so "missing" never gets mistaken for "broken":
// Forward real errors, but let "not found" fall through to a fallback.
if (FailButNotFound(FindUser(id), out var e, out var user)) return e.Trace();
var effective = user ?? User.Guest; // not-found ⇒ user is null here
// Or substitute a fallback inline — only not-found is replaced; real errors pass through:
Outcome<User> u = FindUser(id).IfNotFound(User.Guest);
Outcome<User> u2 = FindUser(id).IfNotFound(() => LoadDefault());
// Test it directly:
if (FindUser(id).IsNotFound()) ...
// When a caller genuinely wants the Option<T> shape back, recover it:
Outcome<Option<User>> maybe = FindUser(id).CheckNotFound(); // not-found ⇒ success(None)
// At an exception boundary, not-found becomes null instead of throwing:
User? user = await ThrowUnlessNotFound(FindUserAsync(id));
Pitfall — document the behaviour. Returning
NotFoundfrom anOutcome<T>is exactly likeList.FindIndexreturning-1: a useful shortcut that is invisible from the signature. If a function usesNotFoundas a normal, expected result, say so in its doc comment — otherwise a caller will treat the absence as an error and propagate it. Reach forOutcome<Option<T>>instead when the distinction must be impossible to miss.
Streaming with IAsyncEnumerable<Outcome<T>>
A risky async stream can be turned into a stream of outcomes with TryCatch, which wraps each
item and emits a trailing failure item if iteration throws:
IAsyncEnumerable<Outcome<Row>> rows = TryCatch(ReadRowsAsync(query));
To consume the whole stream into a list, use MakeList (read-only) or MakeMutableList
(a List<T>). Both short-circuit on the first failure — you get the value list, or the
first error, never a half-built list paired with a swallowed exception:
// Outcome<IReadOnlyList<Row>> — all rows, or the first error encountered
if (Fail(await rows.MakeList(), out var e, out var allRows)) return e.Trace();
Process(allRows);
// MakeMutableList when you need to keep appending afterwards:
if (Fail(await TryCatch(ReadRowsAsync(query)).MakeMutableList(), out var e2, out var list))
return e2.Trace();
list.Add(extraRow);
Both have a selector overload that projects each item while collecting:
// Outcome<IReadOnlyList<string>>
var names = await TryCatch(ReadUsersAsync()).MakeList(u => u.Name);
Note: if the source is known to be empty,
MakeList/MakeMutableListreturnErrorInfo.NotFoundrather than an empty list — handle it withFailButNotFoundorIfNotFoundif "no rows" is acceptable for your case.
The same extension set also offers First, Last, Average, and AverageBy, each returning
an Outcome<T> that short-circuits on the first failing item.
Bridging with exceptions
Outcome<T> interoperates cleanly with exception-based code at both ends.
Pulling exceptions in. TryCatch (sync and async) converts a throwing call into an
Outcome; Try returns a plain (Exception?, T) tuple you can convert later:
Outcome<int> parsed = TryCatch(() => int.Parse(input)); // sync
Pushing errors out. At an API boundary where callers expect exceptions, unwrap:
var user = FindUser(id).Unwrap(); // throws ErrorInfoException if failed
var user2 = await ThrowIfError(LoadProfile(id)); // same, for ValueTask<Outcome<T>>
var maybe = await ThrowUnlessNotFound(Load(id)); // throws on real errors; null on not-found
Custom exceptions → codes. Tag an exception type with [ErrorInfo] and ErrorFrom
will map it to the right Code automatically; otherwise it becomes Unhandled:
[ErrorInfo(DUPLICATION)]
sealed class DuplicateKeyException(string msg) : Exception(msg);
// Anywhere a caught exception needs to become an ErrorInfo:
ErrorInfo info = ErrorFrom.Exception(caughtException); // honours [ErrorInfo]
ErrorInfo prog = ErrorFrom.Program("invalid state"); // quick InvalidRequest with caller name
Cancellation and timeouts get their own codes rather than being lumped into Unhandled, so a
caller can tell "the user walked away" apart from "the service broke":
| Caught exception | Code |
|---|---|
TaskCanceledException wrapping a TimeoutException |
TIMEOUT |
any other OperationCanceledException |
CANCELLED |
TimeoutException |
TIMEOUT |
(The first row is the shape HttpClient produces when its own request timeout elapses.)
if (Fail(await CallService(ct), out var e, out var v)) {
if (e.Is(CANCELLED)) return; // caller gave up — nothing to report
if (e.Is(TIMEOUT)) return e.Trace(); // worth retrying / alerting
return e.Trace();
}
An AggregateException — several operations failing together — keeps all of its failures: each
inner exception is mapped by the same rules and collected into SubErrors, so nothing is silently
dropped. Is(code) searches those sub-errors as well.
ErrorInfoException is the bridge type: it carries Code and DebugInfo, and round-trips
via .ToErrorInfo().
Deprecated: assigning a
LanguageExt.Common.Errorstraight to anOutcome<T>still compiles but is now marked obsolete, as the library moves away from depending on LanguageExt types in its public surface. Convert explicitly instead —ErrorFrom.Exception(error.ToException())— or build theErrorInfoyou actually want.
Composing outcomes (Experimental)
Outcome<T> supports the usual functional combinators:
var len = FindUser(id).Map(u => u.Name.Length);
var inv = FindUser(id).Bind(u => BuildInvoice(u.Id, itemId));
It also offers LINQ query syntax (from … from … select), including an async form over
ValueTask<Outcome<T>>:
var result = from u in FindUser(id)
from i in FindItem(itemId)
select new Invoice(u, i);
⚠️ Caution: the LINQ form is not reliable for
Outcome<T>— prefer the guard style. The query syntax compiles into nestedSelectMany/Selectclosures and, in the async case, a generated state machine. In practice this has broken under code that participates in ambient, flow-sensitive state — for example operations running inside a MongoDB transaction, where the LINQ-generated state machine appears to disrupt the transaction context and the call fails. Until this is understood and fixed, treat LINQ overOutcome<T>as experimental: use theSuccess/Failguard pattern (above) for anything inside a transaction or other context-sensitive scope.
Helper cheat-sheet
| Need | Use |
|---|---|
| Make a success / failure | SuccessOutcome(v) / FailedOutcome<T>(e) (or just return v; / return ErrorInfo.New(code, msg);) |
| Happy-path guard | if (Success(x, out var v, out var e)) |
| Forward an error early | if (Fail(x, out var e, out var v)) return e.Trace(); |
| Treat not-found as non-error | FailButNotFound(x, out var e, out var v) |
Turn a throwing call into an Outcome |
TryCatch(() => ...) / TryCatch(async ...) |
| Collapse to a value | x.Match(onOk, onErr) / x.IfFail(default) |
| Exit to exception-based code | x.Unwrap() / await ThrowIfError(task) |
| Collect an async stream | await TryCatch(stream).MakeList() / .MakeMutableList() |
| Build a structured error | ErrorInfo.New(code, message) / StandardErrorCodes |
| Add app-level trace while propagating | error.Trace() |
| Wrap a low-level error in context | error.Wrap(code, message) |
HTTP helpers
RZ.Foundation.Extensions puts the same railway over HttpClient. Every verb returns
ValueTask<Outcome<HttpResponseMessage>> instead of throwing, and each accepts either a string
URL or a System.Uri:
using RZ.Foundation.Extensions;
var r1 = await http.Get("https://api.example.com/users/42");
var r2 = await http.Get(new Uri("https://api.example.com/users/42")); // Uri overload
var r3 = await http.Post(uri, content); // also Put, Patch, Delete
var r4 = await http.TrySend(request); // named TrySend: HttpClient already has an instance Send
Reading the response is a single chained call — no intermediate variable, and the response is closed for you:
if (Fail(await http.Get(uri).ReadString(), out var e, out var body)) return e.Trace();
var bytes = await http.Get(uri).ReadByteArray();
A non-2xx status is a failure, carrying the HttpError code with the status code and reason
phrase recorded in Data — so a 404 and a 500 are distinguishable without re-reading the response.
JSON over HTTP
// Send
await http.PostJson(uri, dto).MustSucceed(); // asserts 2xx AND an empty body
await http.PutJson(uri, dto).CheckSucceed(); // asserts 2xx, ignores the body
// Receive — chained straight off the verb
if (Fail(await http.Get(uri).DeserializedJson<User>(), out var e, out var user)) return e.Trace();
// ExpectJson / MustSucceed / CheckSucceed also exist on a response you already hold:
if (Fail(await http.Get(uri), out var e2, out var response)) return e2.Trace();
var u2 = await response.ExpectJson<User>(); // 2xx + body, else the server's own error
All of these default to RzRecommendedJsonOptions, so what you send and what you receive agree.
Pass your own JsonSerializerOptions to override.
When the server answers with an error, ExpectJson / MustSucceed / CheckSucceed first try to
read the body as a serialized ErrorInfo — so a failure raised on the server arrives at the client
with its original Code and Message intact. If the body is anything else (HTML, plain text), you
get an HttpError carrying the status and the raw body.
Streaming
ReadStream hands you the body as a Stream. The response stays alive as long as the stream does,
and is disposed with it — so dispose the stream, and pass ResponseHeadersRead to avoid buffering
the whole body first:
var streamed = await http.Get(uri, HttpCompletionOption.ResponseHeadersRead).ReadStream();
if (Fail(streamed, out var e, out var stream)) return e.Trace();
await using (stream) // closes the response too
await stream.CopyToAsync(destination);
Without
ResponseHeadersRead,HttpClientbuffers the entire body before the call returns, which defeats the point of streaming a large download.
Encryption (AES-GCM)
RZ.Foundation.Helpers.Encryption provides authenticated symmetric encryption built on AES-GCM.
Encrypt and Decrypt return Outcome<byte[]>, so failures — a wrong key, a malformed or tampered
payload, or a platform without AES-GCM — flow through the railway instead of throwing.
AES-GCM gives two guarantees for free:
- Confidentiality — a fresh random 12-byte nonce is generated on every
Encrypt, so encrypting the same data twice produces different output. - Integrity / authenticity — every payload carries an authentication tag. If the payload is modified,
or decrypted with the wrong key,
Decryptfails with theEncryption.TAMPER_ERRORcode rather than returning corrupt bytes.
The produced payload is self-describing, laid out as [nonce (12 bytes)][tag (16 bytes)][ciphertext], so
the only thing you need to keep is the key.
Getting a key
A key must be 16, 24 or 32 bytes (AES-128/192/256).
using RZ.Foundation.Helpers;
// A true random 256-bit key (store it somewhere safe):
byte[] key = Encryption.RandomAesKey();
To derive the key deterministically from a string — so it can be re-created on demand and never stored — pick the method that matches your input. The name makes the security choice explicit:
// For a human passphrase / low-entropy text — PBKDF2-HMAC-SHA256 (slow, adds brute-force resistance):
byte[] key = Encryption.CreateAesKeyFromWeakText("correct horse battery staple").Unwrap();
// For text that is ALREADY high-entropy (e.g. a generated / base64 secret) — HKDF-SHA256 (fast):
byte[] key = Encryption.CreateAesKeyFromStrongText(mySecretToken).Unwrap();
// Both default to a 32-byte (AES-256) key; pass n = 16 or 24 for AES-128 / AES-192.
Deterministic derivation is only as strong as the entropy of the input string. For a key that does not need to be reproducible from text, prefer
RandomAesKey().
Encrypting and decrypting
using System.Text;
using RZ.Foundation; // Outcome<T>
using RZ.Foundation.Helpers;
byte[] key = Encryption.RandomAesKey();
byte[] data = Encoding.UTF8.GetBytes("secret message");
Outcome<byte[]> roundTrip =
Encryption.Encrypt(key, data)
.Bind(payload => Encryption.Decrypt(key, payload));
string text = roundTrip.Match(
plain => Encoding.UTF8.GetString(plain),
error => $"failed: {error.Message}");
Detecting tampering
var payload = Encryption.Encrypt(key, data).Unwrap();
payload[^1] ^= 0xFF; // flip a byte of the ciphertext
var result = Encryption.Decrypt(key, payload);
if (result.IfFail(out var error) && error.Is(Encryption.TAMPER_ERROR))
Console.WriteLine("Payload was tampered with (or the key is wrong).");
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- JetBrains.Annotations (>= 2026.2.0)
- LanguageExt.Core (>= 4.4.9)
- RZ.Foundation.AOT (>= 8.4.0)
NuGet packages (14)
Showing the top 5 NuGet packages that depend on RZ.Foundation:
| Package | Downloads |
|---|---|
|
RZ.Foundation.NewtonsoftJson
RZ.Foundation extension for Newtonsoft.JSON |
|
|
TiraxTech.Uri
Customizable, Immutable URI record |
|
|
RZ.Foundation.Blazor
Package Description |
|
|
RZ.Linq.RelationalDatabase
Transform LINQ to SQL text |
|
|
RZ.AspNet.Bootstrapper
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 8.4.0 | 88 | 7/26/2026 |
| 8.3.0 | 158 | 6/29/2026 |
| 8.2.15 | 137 | 4/26/2026 |
| 8.2.14 | 157 | 3/29/2026 |
| 8.2.13 | 131 | 3/28/2026 |
| 8.2.12 | 131 | 3/27/2026 |
| 8.2.11 | 143 | 3/24/2026 |
| 8.2.10 | 117 | 3/23/2026 |
| 8.2.9 | 483 | 2/7/2026 |
| 8.2.6 | 269 | 1/30/2026 |
| 8.1.0 | 224 | 1/28/2026 |
| 7.0.5 | 542 | 6/12/2025 |
| 7.0.4 | 300 | 4/24/2025 |
| 7.0.2 | 369 | 3/9/2025 |
| 7.0.0 | 291 | 2/4/2025 |
| 7.0.0-beta.26 | 132 | 10/15/2024 |
| 7.0.0-beta.12 | 125 | 9/25/2024 |
| 7.0.0-beta.11 | 138 | 9/22/2024 |
| 7.0.0-beta.3 | 154 | 6/23/2024 |
| 7.0.0-alpha.3 | 151 | 6/22/2024 |
Embrace LanguageExt lib.